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/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 6ab989b..5774bae 100644 --- a/cmd/iontk/creds.go +++ b/cmd/iontk/creds.go @@ -5,17 +5,22 @@ 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) } + if err := ui.ThemesErr(); err != "" { + fmt.Fprintln(w, "themes: "+err) + } return creds.Flags, nil } 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 7fc0372..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 @@ -78,6 +68,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 { @@ -154,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 { @@ -184,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 da29df6..a3ef353 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" ) @@ -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"` @@ -164,9 +156,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..5da84d9 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,7 @@ 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() + app.ThemesFile = flagThemes return loadFixtures(cmd.ErrOrStderr()) }, @@ -104,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") @@ -130,10 +123,9 @@ 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 - // 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 0053c8a..10c41d5 100644 --- a/cmd/iontk/search.go +++ b/cmd/iontk/search.go @@ -23,11 +23,9 @@ 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 -// elements, not command results, so "table" and "json" mean different shapes -// here and nothing saved in settings applies. const ( searchTable = "table" searchIDs = "ids" @@ -36,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 @@ -44,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 { @@ -63,6 +57,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 @@ -91,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 @@ -121,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 { @@ -140,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)) @@ -172,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 @@ -195,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() { @@ -268,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)) @@ -303,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", @@ -336,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 { @@ -351,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) @@ -392,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 { @@ -410,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 { @@ -431,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")) } @@ -521,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') @@ -537,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 @@ -575,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/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/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/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 2e42857..543bc84 100644 --- a/internal/app/cli.go +++ b/internal/app/cli.go @@ -1,15 +1,54 @@ package app import ( + "errors" "fmt" "os" + "path/filepath" "strings" + "tyrtop.com/iontk/internal/clock" "tyrtop.com/iontk/internal/config" + "tyrtop.com/iontk/internal/ui" ) -// 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. +var ThemesFile string + +func ThemesPath(st *config.Store) string { + if ThemesFile != "" { + return ThemesFile + } + if st == nil { + return "" + } + 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)) +} + +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.HideThemes(cfg.HiddenThemes) + ui.UseTheme(cfg.Theme) +} + type CredSource string const ( @@ -19,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 { @@ -47,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() } @@ -56,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() @@ -101,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 bdf787c..4bde3d4 100644 --- a/internal/app/cli_test.go +++ b/internal/app/cli_test.go @@ -5,11 +5,11 @@ 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 -// the resolution and any of them changes the answer. func clearCredEnv(t *testing.T) { t.Helper() for _, k := range []string{ @@ -34,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 @@ -129,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) @@ -166,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) } @@ -188,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) } @@ -263,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")}} @@ -283,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) } @@ -302,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" } @@ -310,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() @@ -323,3 +309,50 @@ func TestHeadlessFlagsCarryThePagerHandling(t *testing.T) { t.Error("PagerPrompts is empty, want the scraper armed for firmware that ignores --pager-off") } } + +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) + } +} + +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/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 6083e85..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,22 +26,19 @@ 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, SessionTimeout: 20 * time.Second, RefreshMargin: 75 * time.Second, Concurrency: 10, - Burst: 10, + Burst: 20, DialAttempts: 3, DialTimeout: 10 * time.Second, - RPS: 5, + 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. + // 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", } @@ -106,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 } @@ -121,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 new file mode 100644 index 0000000..e4116f5 --- /dev/null +++ b/internal/clock/clock.go @@ -0,0 +1,86 @@ +package clock + +import ( + "fmt" + "strings" + "sync" + "time" +) + +const Local = "local" + +const UTC = "UTC" + +const layout = "2006-01-02 15:04 MST" + +var ( + mu sync.RWMutex + loc = time.UTC + name = UTC + problem string +) + +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 +} + +func Name() string { + mu.RLock() + defer mu.RUnlock() + return name +} + +func Problem() string { + mu.RLock() + defer mu.RUnlock() + return problem +} + +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..8a3bcb9 --- /dev/null +++ b/internal/clock/clock_test.go @@ -0,0 +1,94 @@ +package clock + +import ( + "strings" + "testing" + "time" +) + +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"}, + {"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) + } + }) + } +} + +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") + } +} + +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) + } +} + +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) + } +} + +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..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,6 +17,24 @@ type ElementCache struct { FetchedAt time.Time `json:"fetched_at"` Elements []api.Element `json:"elements"` Sites map[string]string `json:"sites,omitempty"` + HA map[string]string `json:"ha,omitempty"` +} + +func (s *Store) SaveHA(tsg string, states map[string]string) error { + c, err := s.Cache(tsg) + if err != nil { + return err + } + if c == nil { + 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") } @@ -69,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 new file mode 100644 index 0000000..e5d56fc --- /dev/null +++ b/internal/config/commands.go @@ -0,0 +1,246 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +const HistoryMax = 200 + +const FavouriteSlots = 9 + +type CommandStore struct { + History []CommandUse `json:"history,omitempty"` + Favourites []Favourite `json:"favourites,omitempty"` + Macros []Macro `json:"macros,omitempty"` + Acknowledged []string `json:"acknowledged,omitempty"` +} + +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 string `json:"model,omitempty"` +} + +type Macro struct { + Name string `json:"name"` + Commands []string `json:"commands"` + Model string `json:"model,omitempty"` +} + +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] +} + +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 +} + +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 +} + +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 +} + +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") } + +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 { + 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..388dcef --- /dev/null +++ b/internal/config/commands_test.go @@ -0,0 +1,274 @@ +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) } + +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)) + 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 + 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", + "ping 10.0.0.1", + "dump interface status 8", + } + 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) + } + + if c.History[0].Command != "dump interface status 8" { + t.Errorf("Ranked rewrote the history order: %+v", c.History) + } +} + +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) + } + 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") + } + 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) + } + 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) + } + 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") + } +} + +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()) + + 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") + } + + 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/config.go b/internal/config/config.go index cf7f261..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,25 +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"` - 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 "" @@ -118,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() { @@ -159,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) { @@ -311,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 83a4bbf..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,24 +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())) + 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 6dac277..4035177 100644 --- a/internal/config/reset_test.go +++ b/internal/config/reset_test.go @@ -4,9 +4,9 @@ import ( "os" "path/filepath" "testing" + "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() @@ -35,6 +35,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) } @@ -54,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 { @@ -77,8 +83,7 @@ func TestResetLeavesNothingBehind(t *testing.T) { st.configPath(), st.tenantsPath(), st.cacheDir(), - // Keeping this would leave the identity provider's session cookie, so - // the reset would not have signed anyone out. + st.commandsPath(), st.BrowserDir(), filepath.Join(st.Root, "secrets.json"), } { @@ -107,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/builtin.yaml b/internal/dataview/builtin.yaml index a707368..3e657e6 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,12 +75,41 @@ 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 # 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 @@ -85,13 +122,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 @@ -102,7 +146,14 @@ 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 + # 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 @@ -199,3 +250,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..bf910a0 --- /dev/null +++ b/internal/dataview/compare.go @@ -0,0 +1,113 @@ +package dataview + +import ( + "context" + "strings" +) + +type DeviceOutput map[string]string + +type Verdict int + +const ( + Unknown Verdict = iota + Agree + Differ +) + +type Compared struct { + Label string + Portal string + Device string + Verdict Verdict +} + +type Comparison struct { + View string + Rows []Compared + Commands []string + Errs []string +} + +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 +} + +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 +} + +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) +} + +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 +} + +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..83c1f16 --- /dev/null +++ b/internal/dataview/compare_test.go @@ -0,0 +1,275 @@ +package dataview + +import ( + "strings" + "testing" + + "tyrtop.com/iontk/internal/sase/ioncli" +) + +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"}, + {"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}, + {"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) + } + }) + } +} + +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) + } +} + +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 +} + +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) + } + 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) + } + } +} + +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) + } +} + +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) + } + }) + } +} + +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/dataview_test.go b/internal/dataview/dataview_test.go index db5ad67..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,24 +378,22 @@ 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)}, - 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/fetch.go b/internal/dataview/fetch.go index bafca4b..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,17 +32,11 @@ 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 - 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) @@ -113,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_") { @@ -149,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, ".") { @@ -182,6 +153,14 @@ func itemsAt(ctx map[string]any, path string) ([]any, error) { } } +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..6884904 100644 --- a/internal/dataview/render.go +++ b/internal/dataview/render.go @@ -7,27 +7,23 @@ import ( "text/template" "time" + "tyrtop.com/iontk/internal/clock" "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 } @@ -38,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) { @@ -88,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 { @@ -109,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, "!") { @@ -142,15 +126,12 @@ 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. "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) @@ -163,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 { @@ -212,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 == "" { @@ -245,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 { @@ -254,11 +222,49 @@ var funcs = template.FuncMap{ } return list[0] }, + + "lines": func(v any) []any { + return textLines(v, "") + }, + "grep": func(v any, pat string) []any { + return textLines(v, pat) + }, + "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": func(v any, n int) string { + f := strings.Fields(fmt.Sprint(v)) + if n < 1 || n > len(f) { + return "" + } + return f[n-1] + }, +} + +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 -// 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 @@ -281,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 6d0cd6b..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 ( @@ -16,43 +8,46 @@ import ( "strings" "gopkg.in/yaml.v3" + "tyrtop.com/iontk/internal/sase/ioncli" ) //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 *Compare `yaml:"compare"` +} + +type Compare struct { + When string `yaml:"when"` + Fetch map[string]string `yaml:"fetch"` + Commands map[string]string `yaml:"commands"` + Rows []CompareRow `yaml:"rows"` +} + +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"` - - // 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. + When string `yaml:"when"` + Rows []Row `yaml:"rows"` + + 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 { @@ -63,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 { @@ -87,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 { @@ -136,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 @@ -156,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) @@ -196,6 +173,9 @@ func (s *Set) normalize() error { return err } } + if err := v.Compare.normalize(v.Name); err != nil { + return err + } } return nil } @@ -226,10 +206,41 @@ func (sec *Section) 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 "..". +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 +} + +// 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 e78903d..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" @@ -59,14 +47,40 @@ func (e Element) apiInterfaces() []api.Interface { } out = append(out, in) } + + out = append(out, api.Interface{ + ID: e.vlanInterfaceID(), + Name: "vlan1", + Type: "vlan", + UsedFor: "lan", + AdminUp: true, + MTU: 1500, + Scope: "local", + }) return out } -// 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) vlanInterfaceID() string { + return syntheticID("interface/"+e.ID+"/vlan1", 19) +} + 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", + 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 @@ -82,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)} @@ -95,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) } @@ -126,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 { @@ -186,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 { @@ -208,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" @@ -238,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, @@ -249,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) @@ -265,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) @@ -282,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 } @@ -295,3 +281,77 @@ 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]) } + +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 + } + 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 +} + +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 "" +} + +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}, + } +} + +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, + }, + { + "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..b6223d0 100644 --- a/internal/fixture/portal_test.go +++ b/internal/fixture/portal_test.go @@ -20,14 +20,12 @@ 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) } - if len(snap.Interfaces) != el.ports() { - t.Fatalf("got %d interfaces, want %d", len(snap.Interfaces), el.ports()) + 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" { @@ -43,11 +41,17 @@ 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. - 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]) + } + + 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) } } @@ -78,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 @@ -99,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()) } @@ -115,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) } @@ -128,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()) @@ -148,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 b268020..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,17 +31,29 @@ 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())) + 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) + 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) @@ -54,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) @@ -80,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 @@ -114,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 @@ -132,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) @@ -159,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 := "" @@ -184,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 { @@ -201,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 b398d81..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,7 +175,50 @@ func (m *Manager) CloseActive() { m.notify(s.ElementID) } -// Reap records the element as it goes, so alt+t can reopen it. +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 + 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 +} + func (m *Manager) Reap() int { if m == nil { return 0 @@ -189,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 } @@ -232,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 @@ -257,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 8a5cd41..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() @@ -158,3 +137,23 @@ func (s *Session) Scrollback() int { defer s.emuMu.Unlock() return s.emu.ScrollbackLen() } + +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..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) @@ -123,3 +120,23 @@ func TestScrolledViewDoesNotRaceDeviceOutput(t *testing.T) { } } } + +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/ionterm/session.go b/internal/ionterm/session.go index 0abaf2d..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,7 +232,14 @@ func (s *Session) markHung() { s.markDirty() } -// Hung is a session whose connection stopped answering pings. +// 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 +} + func (s *Session) Hung() bool { s.mu.Lock() defer s.mu.Unlock() @@ -268,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: @@ -278,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 7901fee..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,6 +38,10 @@ func clusterStatusURL(siteID, cid string) string { apiHost, clusterStatusVer, siteID, cid) } +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/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 d2a2226..17ebb06 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,51 +9,35 @@ import ( "net/http" "os" "sort" + "strconv" "strings" "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: @@ -62,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 { @@ -89,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" @@ -114,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] @@ -151,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) @@ -190,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"` @@ -245,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"` @@ -257,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 { @@ -285,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"` @@ -335,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" { @@ -357,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) @@ -376,34 +302,64 @@ 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) +} + +func (s *Client) post(ctx context.Context, what, url string, body []byte) ([]byte, error) { + return s.do(ctx, what, http.MethodPost, url, body) +} + +const rateRetries = 2 + +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): + } + } +} + +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 - // 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") { @@ -417,12 +373,95 @@ 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 +} + +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 body, nil + 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)) +} + +func elementFilter(ids []string) string { + quoted := make([]string, 0, len(ids)) + for _, id := range ids { + quoted = append(quoted, `"`+id+`"`) + } + return `{"element_id":{"in":[` + strings.Join(quoted, ",") + `]}}` +} + +const ClusterQueryLimit = 200 + +const clusterQueryPages = 20 + +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 int64 `json:"last_status_update_ts"` +} + +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 + } + } + 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..481d4de 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" @@ -60,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", @@ -80,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 { @@ -101,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 @@ -126,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, @@ -151,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 @@ -207,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) { @@ -238,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) { @@ -248,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", @@ -282,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) } @@ -292,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) } @@ -342,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) } @@ -356,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) @@ -365,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)) @@ -380,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, @@ -443,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{ @@ -467,3 +434,142 @@ func TestSnapshotWithNoSiteDoesNotAskForInterfaces(t *testing.T) { t.Errorf("StatusErr = %v, want the status still fetched", snap.StatusErr) } } + +// 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", + "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}, + { + 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) + } + 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) + } + 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) + } + } + }) + } +} + +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") + } +} + +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/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 5ed1979..8f015fd 100644 --- a/internal/saseauth/browser_test.go +++ b/internal/saseauth/browser_test.go @@ -2,38 +2,54 @@ package saseauth import ( "context" + "fmt" "net/http" "net/http/httptest" "net/url" "os" "os/exec" + "path/filepath" "strings" "sync" "testing" "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 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() { 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 "" } -// 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 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 +} + func needsDisplay(t *testing.T) { t.Helper() if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" { @@ -41,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 @@ -148,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) @@ -184,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() @@ -208,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) @@ -244,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) @@ -296,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) @@ -313,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]) } @@ -334,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 6010e84..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 @@ -34,6 +27,7 @@ type Record struct { Model string Software string HWID string + Serial string Connected bool } @@ -46,6 +40,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 { @@ -64,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 "-" @@ -73,44 +66,32 @@ 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}, + {Value: strings.ToLower(r.Serial), Exact: true}, {Value: strings.ToLower(r.Software)}, {Value: strings.ToLower(r.TSG), Exact: true}, {Value: r.State()}, } } -// 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 new file mode 100644 index 0000000..89b7cd4 --- /dev/null +++ b/internal/tui/appearance.go @@ -0,0 +1,423 @@ +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" +) + +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())) +} + +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 +} + +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 { + return nm, nil + } + nm.form, nm.formKind = nil, formNone + nm.phase = nm.formReturn + 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)...) + } + + themes := ui.Themes() + 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 { + plain := fmt.Sprintf("%-12s", ui.Truncate(t.Name, 12)) + name := ui.StyleElement.Render(plain) + tag := "" + if strings.EqualFold(t.Name, cur.Name) { + name = ui.StyleRow.Render(plain) + } + 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))) + } + + 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[min(at, len(themes)-1)])...) +} + +func indexOfTheme(themes []ui.Theme, name string) int { + for i, t := range themes { + if strings.EqualFold(t.Name, name) { + return i + } + } + return -1 +} + +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}, +} + +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), + 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]") + } + 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(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))) + } + + 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 { + 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, " ") +} + +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) + } + 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 + } + if !onScheme { + return m.openNewThemeForm() + } + + 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 +} + +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()) + + next, cmd := m.withSpinner() + if repaint { + return next, tea.Batch(cmd, tea.ClearScreen) + } + return next, cmd +} + +func profileLabel() string { + if n := ui.ProfileName(); n != "" { + return n + } + 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 new file mode 100644 index 0000000..4a13760 --- /dev/null +++ b/internal/tui/appearance_test.go @@ -0,0 +1,502 @@ +package tui + +import ( + "context" + "os" + "path/filepath" + "slices" + "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" +) + +func pinZone(t *testing.T) { + t.Helper() + t.Cleanup(func() { _ = clock.Use(clock.UTC) }) +} + +func pinTheme(t *testing.T) { + t.Helper() + t.Cleanup(func() { + _ = ui.LoadThemes("") + ui.Apply(ui.Default()) + }) +} + +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) + } + + 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()) + } +} + +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()) + } +} + +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, "") { + t.Errorf("nothing marks the theme in use:\n%s", out) + } + 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()) +} + +func TestPickingAThemeAppliesAndPersistsIt(t *testing.T) { + pinTheme(t) + m := settingsModel(t) + m.setSection, m.setFocus = secTheme, focusContent + + want := 0 + for i, th := range ui.Themes() { + if th.Name == "mono" { + want = i + 1 + } + } + 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) + } + 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")) + } +} + +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) + } +} + +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 + 1 + + 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) + } + for _, want := range []string{"store-0003-rtr-01", "Store 0003", "ion 1200", + "connected", "offline", "dump device status", "elements"} { + 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") + } + + 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) + } +} + +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") + } + 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) + } + if !strings.Contains(plain(monoLines), mono.Spinner) { + t.Errorf("the preview does not name mono's spinner %q", mono.Spinner) + } +} + +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 + 1 + + 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) + } + if got := plain(m.spinner.View()); got == "" { + t.Error("the model's spinner renders nothing after the change") + } +} + +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) + } +} + +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 535c492..e59d275 100644 --- a/internal/tui/batchui.go +++ b/internal/tui/batchui.go @@ -11,15 +11,12 @@ 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" "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), @@ -74,7 +71,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, @@ -107,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 new file mode 100644 index 0000000..55bb92d --- /dev/null +++ b/internal/tui/bench_test.go @@ -0,0 +1,71 @@ +package tui + +import ( + "context" + "fmt" + "testing" + + "tyrtop.com/iontk/internal/config" + "tyrtop.com/iontk/internal/sase/api" +) + +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) + } +} + +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..bc6c08e --- /dev/null +++ b/internal/tui/clipboard.go @@ -0,0 +1,125 @@ +package tui + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "tyrtop.com/iontk/internal/output" + "tyrtop.com/iontk/internal/ui" +) + +const clipLimit = 100 << 10 + +const copyKeyHint = "alt+y copy" + +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) +} + +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:] + } + 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) +} + +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()) +} + +func (m tuiModel) copyDataPage() (tea.Model, tea.Cmd) { + tab, ok := m.activeData() + if !ok { + m.status = "nothing to copy here" + return m, nil + } + 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")) +} + +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() +} + +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()) +} + +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..3b41988 --- /dev/null +++ b/internal/tui/clipboard_test.go @@ -0,0 +1,221 @@ +package tui + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "tyrtop.com/iontk/internal/app" + "tyrtop.com/iontk/internal/config" +) + +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()) +} + +func TestTheElementListNoLongerCopiesIds(t *testing.T) { + m := pickModel(t) + + mm, cmd := m.Update(tea.KeyPressMsg(altKey('y'))) + m = mm.(tuiModel) + 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 nothing 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) + } +} + +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) + } +} + +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 TestTheMacroPageNoLongerCopies(t *testing.T) { + m := macroModel(t) + m.macroCursor = 1 + + mm, cmd := m.Update(tea.KeyPressMsg(altKey('y'))) + m = mm.(tuiModel) + if cmd != nil { + 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 out := plain(m.viewMacros()); strings.Contains(out, "alt+y") { + t.Errorf("the macro page still offers alt+y:\n%s", out) + } +} + +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) + } +} + +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) + } +} + +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) + } + if strings.Contains(conn.sent(), "y") { + t.Errorf("alt+y reached the device: %q", conn.sent()) + } +} + +func TestPastingCommandsIntoTheBatchBoxSplitsThem(t *testing.T) { + m := pickModel(t) + m.phase = phaseCommand + 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]) + } + } +} + +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) + } + 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) + } + } +} + +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") + } + 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 new file mode 100644 index 0000000..41e0fe4 --- /dev/null +++ b/internal/tui/commands.go @@ -0,0 +1,148 @@ +package tui + +import ( + "strconv" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "tyrtop.com/iontk/internal/config" + "tyrtop.com/iontk/internal/ioncmd" +) + +type commandsSavedMsg struct{ err string } + +func (m *tuiModel) countUse(command, model string) { + if m.cmds == nil { + m.cmds = &config.CommandStore{} + } + m.cmds.Record(command, model, time.Now()) +} + +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 + } + 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) { + if msg.err != "" && m.status == "" { + m.status = "could not save command history: " + msg.err + } + return m, nil +} + +func (m tuiModel) modelOf(tsg, eid string) string { + if el, ok := m.elementByKey(tsg + "/" + eid); ok { + return el.Model + } + return "" +} + +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)) +} + +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) +} + +func (m tuiModel) activeModel() string { + s := m.sessions.Active() + if s == nil { + return "" + } + return m.modelOf(m.tsgForElement(s.ElementID), s.ElementID) +} + +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 +} + +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 +} + +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..af4c71c --- /dev/null +++ b/internal/tui/commands_test.go @@ -0,0 +1,259 @@ +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" +) + +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 +} + +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) + } + if got[0].Model != "ion 1200" { + t.Errorf("model = %q, want the element's", got[0].Model) + } + + 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) + } +} + +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") + } +} + +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) + } + } + } +} + +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]) + } + if strings.Contains(m.slashItems[0], "ion 1200") { + t.Errorf("first row = %q, want no model tag for this model", m.slashItems[0]) + } + 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) + } + + 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) + } + + 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()) +} + +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) + } + 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) + } +} + +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/compare.go b/internal/tui/compare.go new file mode 100644 index 0000000..bcefecd --- /dev/null +++ b/internal/tui/compare.go @@ -0,0 +1,209 @@ +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" +) + +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 +} + +type deviceRunner interface { + ToolkitSession(ctx context.Context, eid, prompt, user, pass string, cmds []string) ([]ioncli.CommandOutput, error) +} + +func (m tuiModel) runnerFor(c *api.Client) deviceRunner { + if app.Fixture != nil { + return app.Fixture.SessionClient(app.Defaults.PagerOff, app.Defaults.PagerPrompts) + } + return c +} + +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) +} + +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 +} + +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) + } +} + +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)...) +} + +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)) } + + 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) + 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 + } + 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..d39ec0b --- /dev/null +++ b/internal/tui/compare_test.go @@ -0,0 +1,212 @@ +package tui + +import ( + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "tyrtop.com/iontk/internal/config" + "tyrtop.com/iontk/internal/dataview" +) + +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 +` + +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\" }}" +` + +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) + + 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", + "✗ Model", + "· Serial", + "ion 9999", + } { + if !strings.Contains(page, want) { + t.Errorf("the comparison is missing %q:\n%s", want, page) + } + } + if got := m.status; !strings.Contains(got, "1 of 3") { + t.Errorf("status = %q, want the number of disagreements", got) + } +} + +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) + } +} + +func TestComparisonsTheElementCannotAnswerAreNotOffered(t *testing.T) { + m, set := fixtureTUIWith(t, haFixtureYAML) + m.phase = phasePicking + m.mode = modeData + m.rebuildRows() + + 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") + } +} + +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) + } + } +} + +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 = "" }) +} + +func runCompare(t *testing.T, m tuiModel, cmd tea.Cmd) tuiModel { + t.Helper() + if cmd == nil { + t.Fatal("no command to run") + } + 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/connecting.go b/internal/tui/connecting.go index cddeba2..7578dd1 100644 --- a/internal/tui/connecting.go +++ b/internal/tui/connecting.go @@ -5,26 +5,19 @@ import ( "strings" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "tyrtop.com/iontk/internal/sase/api" "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 @@ -32,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 { @@ -50,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 @@ -73,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}) } @@ -86,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) } @@ -96,11 +80,11 @@ func (m *tuiModel) dialFailed(eid, reason string) { st.failed = true st.lastErr = reason m.setDial(eid, st) + 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 { @@ -110,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 == "" { @@ -131,7 +112,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(), @@ -139,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 { @@ -150,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) @@ -163,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": @@ -179,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 } @@ -199,35 +170,36 @@ 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 { +func (m *tuiModel) rowStatus(el elementRow) string { + glyph, style := m.rowStatusOf(el) + return style.Render(glyph) +} + +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() + 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 -// 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 { @@ -240,8 +212,12 @@ func (m tuiModel) connectBar() string { var parts []string if st, ok := m.dialing[s.ElementID]; ok && !st.failed { + 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,36 +227,39 @@ 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(): + parts = append(parts, m.spinner.View()+" "+ui.StyleWarn.Render("waiting for the ion")) default: - parts = append(parts, ui.StyleOK.Render(ui.MarkCheck.String()+" connected")) + 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 { + 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 { +func (m tuiModel) awaitingDevice() bool { + s := m.sessions.Active() + return s != nil && s.Err() == nil && !s.Hung() && !s.Ended() && !s.Answered() +} + +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. 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 261a7a8..85b7ce6 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" @@ -49,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)) @@ -68,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") @@ -109,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) } @@ -123,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")) @@ -193,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) } @@ -205,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") } @@ -240,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) } @@ -271,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) } @@ -299,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") } @@ -334,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") } @@ -343,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 @@ -376,9 +360,94 @@ 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) } } + +func TestTheConnectBarNamesBothHalves(t *testing.T) { + tests := []struct { + name string + connected bool + dialing bool + kill bool + 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"}, + }, + { + 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) + } + } + }) + } +} + +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 f8b85eb..8314b65 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" @@ -13,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 @@ -30,26 +26,19 @@ 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 + + cmp compareState } type dataPane struct { @@ -73,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, @@ -100,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 @@ -115,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() @@ -138,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) @@ -151,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 @@ -185,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 { @@ -197,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{} @@ -234,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 != "" { @@ -252,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 { @@ -294,18 +258,53 @@ 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.dataFilter.SetValue("") + m.dataFilterOn = true + return m, m.dataFilter.Focus() + } + case "enter": + if m.dataFilterOn { + m.dataFilterOn = false + m.dataFilter.Blur() + return m, nil + } case "r": - return m.refreshData() + if !m.dataFilterOn { + return m.refreshData() + } + case "c": + 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() + + 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++ @@ -319,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": @@ -333,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 @@ -360,16 +354,61 @@ func clampScroll(at, total, rows int) int { return max(0, min(at, max(0, total-rows))) } +func (m *tuiModel) visiblePage(tab dataTab) dataPage { + page := m.dataPage(tab) + q := strings.ToLower(strings.TrimSpace(m.dataFilter.Value())) + if q == "" { + return page + } + + 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) + } + 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()) } -// 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 []int } func (p dataPage) String() string { return strings.Join(p.lines, "\n") } @@ -380,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 @@ -408,7 +444,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))) } @@ -416,13 +455,11 @@ 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 } -// 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 @@ -434,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) @@ -493,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 { @@ -511,12 +543,22 @@ 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) - // 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]) @@ -536,8 +578,43 @@ 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 · / 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 +} + +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": + 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) + 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 926a959..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,24 +63,21 @@ 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", "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()) - for _, want := range []string{"store-0003-rtr-01", "Store 0003", "4 views", "Overview", "of 5"} { + 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) } @@ -99,7 +89,32 @@ func TestRichDataModeLoadsEveryViewOntoOnePage(t *testing.T) { } } -// Which port carries which circuit, from both sides of the join. +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) + } +} + func TestThePageTiesPortsToCircuits(t *testing.T) { m, set := fixtureTUIWith(t, haFixtureYAML) m.phase = phasePicking @@ -110,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) @@ -120,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 @@ -155,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 { @@ -167,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 { @@ -183,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 @@ -210,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 := ` @@ -252,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 @@ -279,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 { @@ -287,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"}} @@ -302,33 +299,27 @@ 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) } 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) } } } -// 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) } @@ -346,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) @@ -378,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 { @@ -401,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 { @@ -431,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 @@ -449,20 +433,18 @@ 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 m.setSection = secGeneral m.setFocus = focusContent - m.setCursor = 3 + m.setCursor = jumpRow first := m.pageStep() next, _ := m.onGeneralKey("enter") @@ -471,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) @@ -480,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) @@ -489,3 +469,113 @@ func TestThePageStepSettingCycles(t *testing.T) { t.Errorf("pageStep() = %d after a full cycle, want %d", got, first) } } + +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) + } + 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) + } + + 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) + } + } +} + +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) + } + + 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) + } + for _, want := range []string{"Interfaces", "PORT"} { + if !strings.Contains(text, want) { + t.Errorf("the filtered page is missing %q:\n%s", want, text) + } + } + if strings.Contains(text, "Rebooted") { + t.Errorf("the filtered page kept lines that do not match:\n%s", text) + } + + 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) + } + + 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") + } + 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 new file mode 100644 index 0000000..9dee98d --- /dev/null +++ b/internal/tui/favourites.go @@ -0,0 +1,189 @@ +package tui + +import ( + "fmt" + "strconv" + "strings" + + tea "charm.land/bubbletea/v2" + "tyrtop.com/iontk/internal/config" + "tyrtop.com/iontk/internal/ui" +) + +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 +} + +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) + 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) + 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) + } + 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 +} + +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) +} + +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}) +} + +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) +} + +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 + } + 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 · "+copyKeyHint+" · 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..c3de7d5 --- /dev/null +++ b/internal/tui/favourites_test.go @@ -0,0 +1,226 @@ +package tui + +import ( + "context" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "tyrtop.com/iontk/internal/ionterm" +) + +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 +} + +func altKey(r rune) tea.Key { return tea.Key{Code: r, Mod: tea.ModAlt} } + +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") }) + 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) + } +} + +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) + } + } + waitForTUI(t, func() bool { return strings.Contains(conn.sent(), "dump interface status 8") }) +} + +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) + } + + 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) + } + } + + 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") + } + + 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") + } +} + +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) + + 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) + } +} + +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()) + } + 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) + } + + 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..038449d 100644 --- a/internal/tui/filter_test.go +++ b/internal/tui/filter_test.go @@ -1,6 +1,7 @@ package tui import ( + "fmt" "strings" "testing" @@ -46,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"}, @@ -133,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() { @@ -170,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) @@ -192,11 +188,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) } @@ -218,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) @@ -325,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 @@ -398,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)) } @@ -428,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 { @@ -452,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 @@ -477,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)) } @@ -507,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) @@ -536,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) @@ -583,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") @@ -595,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 @@ -633,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 @@ -744,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") @@ -756,3 +734,386 @@ func TestArrowsStillMoveTheListWhileSearching(t *testing.T) { t.Errorf("filter.Value() = %q, want the query intact", got) } } + +func TestBackspacingTheQueryEmptiesItThenLeaves(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") + } + + backspace := func(m tuiModel) tuiModel { + t.Helper() + next, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyBackspace})) + return next.(tuiModel) + } + + for range "s0" { + m = backspace(m) + } + 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 empty", m.filter.Value()) + } + if len(m.matches) != len(m.all) { + t.Errorf("%d matches, want the whole list of %d", len(m.matches), len(m.all)) + } + + 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) + } + + 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) + } +} + +func TestBackspacingTheMacroQueryEmptiesItThenLeaves(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 closed on the press that emptied the query") + } + if len(m.macroRows()) != 2 { + t.Errorf("%d rows, want every macro back", len(m.macroRows())) + } + + 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") + } +} + +func TestHoldingKWalksBackUpTheList(t *testing.T) { + m := pickModel(t) + m.phase = phasePicking + m.height = 12 + 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())) + } + + 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) + } + } +} + +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) + } +} + +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) + } +} + +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) + } +} + +func TestConnectedRowsAreGreenAndOfflineKeepsRed(t *testing.T) { + m := multiModel(t) + m.mergeTenantElements("111", "Tractor Supply", []api.Element{ + {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() + + 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") + } + + 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") + } + if strings.Contains(out, ui.StyleErr.Render("connected")) { + t.Error("connected is rendered in the error colour") + } +} + +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")) + } + + 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()) +} + +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 new file mode 100644 index 0000000..a8a66d5 --- /dev/null +++ b/internal/tui/ha.go @@ -0,0 +1,526 @@ +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" +) + +const ( + haActive = "active" + haStandby = "standby" + haUnknown = "unknown" +) + +const haAnswered = "ok" + +type haWork struct { + 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{} + for _, el := range els { + cid := el.ClusterID() + if cid == "" { + continue + } + w.elements = append(w.elements, el.ID) + if seen[cid] { + continue + } + seen[cid] = true + if el.SiteID == "" { + w.noSite = append(w.noSite, cid) + continue + } + w.pairs = append(w.pairs, [2]string{el.SiteID, cid}) + } + return w +} + +const haPace = 400 * time.Millisecond + +type haTickMsg struct{ tsg string } + +func haTick(tsg string) tea.Cmd { + return tea.Tick(haPace, func(time.Time) tea.Msg { return haTickMsg{tsg: tsg} }) +} + +const haBatch = 16 + +const haFetchWorkers = 8 + +type haStatesMsg struct { + tsg string + states map[string]string + bulk string + answered []string + failed map[string]string + rest [][2]string + restElements []string +} + +func loadHABulk(ctx context.Context, c *api.Client, tsg string, w haWork) tea.Cmd { + return loadHAChunk(ctx, c, tsg, w, w.elements) +} + +func loadHAChunk(ctx context.Context, c *api.Client, tsg string, w haWork, ids []string) tea.Cmd { + if len(ids) == 0 { + return nil + } + 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 { + 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 + } + + 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, + } + } +} + +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 +} + +func loadHA(ctx context.Context, c *api.Client, tsg string, pairs [][2]string) tea.Cmd { + if c == nil || len(pairs) == 0 { + return nil + } + 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) + 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 { + 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 { + words[mem.ElementID] = memberWord(mem, hasActive) + } + + mu.Lock() + answered = append(answered, p[1]) + 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, answered: answered, failed: failed, rest: rest} + } +} + +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.bulk != "" { + m.haBulk = msg.bulk + } + 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 + } + for cid, why := range msg.failed { + m.haCluster[msg.tsg+"/"+cid] = why + m.haFailed++ + if m.haErr == "" { + m.haErr = why + } + } + 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) + } + 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() + + 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 { + 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) +} + +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)} + m.markHAOut(tsg) + return m, loadHAChunk(m.ctx, m.pool.get(tsg), tsg, w, w.elements) +} + +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 +} + +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{} + } + var cmds []tea.Cmd + for tsg, ids := range m.haWanted() { + if m.haInFlight[tsg] { + continue + } + 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) + } + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func saveHA(st *config.Store, tsg string, states map[string]string) tea.Cmd { + if st == nil || len(states) == 0 { + return nil + } + 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 + } +} + +func (m *tuiModel) haCell(r elementRow) string { + word, cached := m.haState(r) + if word == "" { + return "" + } + return haWord(word, cached) +} + +func (m *tuiModel) haState(r elementRow) (string, bool) { + if r.ClusterID() == "" { + return "", false + } + if word := m.ha[r.key()]; word != "" { + return word, false + } + if word := m.haCached[r.key()]; word != "" { + return word, true + } + + switch { + case r.SiteID == "": + return "ha?", false + case m.haCluster[r.TSG+"/"+r.ClusterID()] == haAnswered: + return haUnknown, false + case m.haCluster[r.TSG+"/"+r.ClusterID()] != "": + return "ha?", false + } + return "loading", false +} + +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: + out = ui.StyleOK.Bold(true).Render(haActive) + case haStandby: + out = ui.StyleElement.Render(haStandby) + case haUnknown: + out = ui.StyleWarn.Render(haUnknown) + default: + out = ui.StyleDim.Render(word) + } + if cached { + out += ui.StyleDim.Render("*") + } + return out +} + +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) +} + +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" +} + +func (m tuiModel) nextPairs(tsg string, rest [][2]string) [][2]string { + if len(rest) < 2 { + return rest + } + + 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 + } + 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 +} + +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 new file mode 100644 index 0000000..2bfe882 --- /dev/null +++ b/internal/tui/ha_test.go @@ -0,0 +1,467 @@ +package tui + +import ( + "fmt" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "tyrtop.com/iontk/internal/sase/api" +) + +func TestHAStatesAreFilledIntoTheList(t *testing.T) { + m, set := fixtureTUIWith(t, haFixtureYAML) + m.phase = phasePicking + m.rebuildRows() + + 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) + } + 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) + } + + 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, w.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) + + 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) + } +} + +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") + } +} + +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"}, + {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) + } + 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) + } +} + +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 +} + +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() +} + +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) + } + + 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 { + 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) + } + } +} + +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) + } +} + +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) + } +} + +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) + } +} + +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) + } +} + +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) + } + + 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) + } +} + +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) + } + + 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) + } +} + +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) + } + if len(c.Elements) == 0 { + t.Error("saving the sweep dropped the cached elements") + } +} + +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)) + } + + 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) + } + + 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) + } + + 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]) + } + } +} + +func TestASearchAsksForItsRowsWithoutWaitingForTheFill(t *testing.T) { + m, _ := fixtureTUIWith(t, manyClusterFixture(20)) + m.phase = phasePicking + m.width, m.height = 120, 24 + m.rebuildRows() + + 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") + } + + 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") + } + if again := m.askVisibleHA(); again != nil { + t.Error("a second ask went out while the first was still in flight") + } + + 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) + } + } +} + +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") + } + 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 new file mode 100644 index 0000000..677dc8d --- /dev/null +++ b/internal/tui/macros.go @@ -0,0 +1,462 @@ +package tui + +import ( + "fmt" + "slices" + "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" +) + +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 +} + +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 +} + +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 +} + +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) { + armed := m.macroDel + if key != "x" { + m.macroDel = "" + } + + 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 + } + 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 + } + + switch key { + case "esc", "q": + m.phase = phaseMenu + m.status = "" + return m, nil + case "/": + m.macroFilter.SetValue("") + m.macroCursor = 0 + 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 "x": + return m.armOrDeleteMacro(armed) + case "u": + return m.undoMacroDelete() + case "enter": + if mac, ok := m.macroAt(m.macroCursor); ok { + return m.armMacro(mac) + } + return m.openMacroForm(config.Macro{}) + } + return m, nil +} + +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 + " · 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) +} + +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" + + if len(m.all) == 0 { + return m.startContinue() + } + m.phase = phasePicking + m.rebuildRows() + return m, nil +} + +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) +} + +func (m tuiModel) runMacro(mac config.Macro, rows []elementRow) (tea.Model, tea.Cmd) { + 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 + } + + 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) +} + +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 "" +} + +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 +} + +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) +} + +func macroCommandLabel(i int) string { return fmt.Sprintf("command %d", i+1) } + +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) +} + +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) + } + + 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) +} + +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() +} + +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:]...) + 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() +} + +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 +} + +func (m tuiModel) submitMacro() (tea.Model, tea.Cmd) { + name := m.formValue(0) + mac := config.Macro{Name: name, Commands: m.macroFormCommands(), Model: m.selectionModel()} + + 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) +} + +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") + + 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" + } + + 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 { + b.WriteString(ui.StyleDim.Render(" nothing matches the filter") + "\n") + } + + for i, mac := range list { + 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)) + + 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(model) + } + + 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 · e edit · x delete · u undo · / filter · esc menu")) + return b.String() +} + +func (m tuiModel) modeLabel() string { + label := ui.StyleElement.Render(ui.Title(m.mode.String()) + " Mode") + if m.mode == modeMacro && m.armedMacro != "" { + label += ui.StyleCmd.Render(" " + m.armedMacro) + } + return label +} + +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() +} + +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 new file mode 100644 index 0000000..06240eb --- /dev/null +++ b/internal/tui/macros_test.go @@ -0,0 +1,601 @@ +package tui + +import ( + "slices" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "tyrtop.com/iontk/internal/config" + "tyrtop.com/iontk/internal/sase/api" +) + +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) + } + } + 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) + } + 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) + } + } + + 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) + } + 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.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("the second x 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") + } +} + +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) + } + } + if got.events != nil { + t.Error("the run stream was opened for a refused macro") + } + + 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) + } +} + +func TestACrossModelMacroAsksOnceAndThenNeverAgain(t *testing.T) { + m := macroModel(t) + mac, _ := m.cmds.Macro("ha check") + 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) + } + } + + 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)) + } + + 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") + } + + 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())) + } +} + +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 +} + +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) + } + 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) + } + } + + 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) + } +} + +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() + + 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) + } + + 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) + } +} + +func TestTheMacroFormGrowsALineAtATime(t *testing.T) { + m := macroModel(t) + 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) + } + 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") + + 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") + + 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) +} + +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) + if len(m.form) != 4 { + t.Fatalf("form = %d fields, want the macro's two commands plus an empty line", len(m.form)) + } + + 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) + } + + 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) + } + + 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)) + } + + 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) + } + 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) + } + + 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) + } + + 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) + } +} + +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) + } +} + +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) + } + + 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) + } + if got.mode != modeShell { + t.Errorf("mode = %v, want the saved default", got.mode) + } +} + +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)) + } + + 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) + } + 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) + } + + 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) + } + } + } +} + +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) + } + } + + 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) + } + 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) + } +} + +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 a0694a4..b132907 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -4,78 +4,43 @@ 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: + case 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 - } +const wheelStep = 3 +func (m tuiModel) onWheel(up bool) (tea.Model, tea.Cmd) { 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() + key := "alt+down" if up { - m.sesScroll = min(m.sesScroll+wheelRows, s.MaxScroll(rows)) - } else { - m.sesScroll = max(0, m.sesScroll-wheelRows) + key = "alt+up" + } + next := m + for range wheelStep { + next, _ = next.onScrollKey(s, key) } + return next, nil - // 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() + key := "down" if up { - tab.scroll -= wheelRows - } else { - tab.scroll += wheelRows + key = "up" + } + var model tea.Model = m + for range wheelStep { + model, _ = model.(tuiModel).onDataKey(key) } - tab.scroll = clampScroll(tab.scroll, len(m.dataPage(tab).lines), rows) - m.data[m.dataActive] = tab + return model, nil } return m, nil } diff --git a/internal/tui/mouse_test.go b/internal/tui/mouse_test.go index c3f2013..c63b026 100644 --- a/internal/tui/mouse_test.go +++ b/internal/tui/mouse_test.go @@ -1,98 +1,36 @@ 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) +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, _ = down.Update(wheel(tea.MouseWheelUp)) - if c := next.(tuiModel).table.Cursor(); c != 0 { - t.Errorf("cursor = %d after scrolling back up, want 0", c) + 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") } -} - -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 + held := m.sesScroll - 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) + 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) } - 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 range 20 { + next, _ = m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + m = next.(tuiModel) } - 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) - } + 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 a39e238..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,6 +167,12 @@ func (m *tuiModel) loadFromCache() { } m.sites[k] = v } + 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 } @@ -203,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 { @@ -244,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 { @@ -257,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 } @@ -275,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) } @@ -285,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 { @@ -306,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 { @@ -329,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 { @@ -357,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 { @@ -367,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 @@ -382,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 { @@ -401,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 { @@ -419,34 +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") } - return "" + + d := m.cacheAgeNow() + if m.cacheState == cacheUpdated && d < cacheFresh { + return ui.StyleOK.Render("Elements refreshed!") + ago(d) + } + return ui.StyleDim.Render("Elements cached") + ago(d) +} + +func (m tuiModel) haNote() string { + 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 "" + } + + 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 @@ -468,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} @@ -483,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{} @@ -511,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 { @@ -530,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) { @@ -541,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) @@ -558,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 { @@ -569,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 @@ -594,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 } @@ -625,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 @@ -638,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 @@ -651,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 @@ -664,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 { @@ -679,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 0889050..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) { @@ -549,7 +530,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 { @@ -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 f30bc6f..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 @@ -43,6 +41,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) { @@ -62,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 @@ -88,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 ea9b3a2..3beb88d 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,52 @@ func newResultsTable() table.Model { }), table.WithFocused(true), ) + t.SetStyles(tableStyles()) + return t +} + +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 +} + +func (m tuiModel) resOutputRoom() int { + used := 4 + m.resTableHeight() + return max(2, m.height-used-2-m.statsRows()) +} + +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 { @@ -46,6 +92,8 @@ func (m tuiModel) completedCount() int { } func (m *tuiModel) applyResultFilter() { + was := m.cursorResultID() + q := strings.ToLower(strings.TrimSpace(m.resFilter.Value())) m.resMatches = m.resMatches[:0] @@ -69,7 +117,25 @@ func (m *tuiModel) applyResultFilter() { } m.resFilter.SetSuggestions(names) m.rebuildResultRows() + m.keepResultCursorOn(was) +} +func (m tuiModel) cursorResultID() string { + if idx, ok := m.currentResult(); ok { + return m.results[idx].ElementID + } + return "" +} + +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)) } @@ -122,16 +188,14 @@ 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: m.resOpen = false - m.resScroll = 0 + m.resScroll, m.resLine = 0, 0 m.rebuildResultRows() case m.resFilterOn || m.resFilter.Value() != "": m.resFilterOn = false @@ -143,35 +207,61 @@ 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" { + if m.resFilterOn { + break + } + } 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 + case "alt+y": + return m.copyResults() + case "f", "o", "s": if !m.resFilterOn { return m.onResultsOutputKey(key) @@ -179,14 +269,21 @@ 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 { + 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() @@ -198,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() @@ -265,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()) @@ -301,14 +392,16 @@ 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 { + 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,19 @@ 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 · " + copyKeyHint + " run as json · 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 · " + copyKeyHint + " output · esc/q close" + 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..1a233e6 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" @@ -95,23 +96,55 @@ func TestResultsEnterExpandsInlineNotANewTab(t *testing.T) { } } -func TestResultsScrollingDownClosesTheExpansion(t *testing.T) { +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()) + } + 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") + } + 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") + 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) } } @@ -286,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") { @@ -297,3 +328,82 @@ func TestResultsHeaderNamesTheSaveFormat(t *testing.T) { t.Errorf("results header claims an orientation json does not use:\n%s", got) } } + +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() + } + + 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]) + } + + 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) + } + + 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) + } + + 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) + } +} + +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/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 3e93077..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,32 +145,25 @@ 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) } } -func TestWheelScrollsTheSessionPane(t *testing.T) { +func TestOnlyTheScrollingPanesAskForTheMouse(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) + 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) + } } -} - -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, 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) + } } } diff --git a/internal/tui/searchui.go b/internal/tui/searchui.go index a73ddf6..387a576 100644 --- a/internal/tui/searchui.go +++ b/internal/tui/searchui.go @@ -11,42 +11,28 @@ 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" ) -// 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 { } } @@ -72,7 +58,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, @@ -103,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 80cd538..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) @@ -147,14 +146,13 @@ 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) } - // 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 187221c..bc58972 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,25 +18,12 @@ 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) { 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() @@ -97,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) } @@ -121,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) @@ -169,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 { @@ -211,12 +185,11 @@ func (m tuiModel) selectTab(i int) tuiModel { func (m tuiModel) onTabKey(key string) (tuiModel, bool) { switch key { - case "alt+right": + 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. if m.phase == phaseData { return m.closeDataTab(), true } @@ -235,9 +208,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 +215,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 +231,32 @@ 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() + } + + 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() + if key == "alt+y" { + return m.copySession() } - if s.Hung() { + + if key == "alt+r" { + if s.Err() != nil || s.Hung() || s.Ended() { + return m.redialActive() + } + m.status = "this session is still answering — alt+k closes it" + return m, nil + } + + if s.Err() != nil || s.Hung() { return m, nil } @@ -282,16 +268,16 @@ 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 + + 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 -// 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 { @@ -312,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 = "" @@ -331,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 { @@ -339,18 +320,41 @@ func (m tuiModel) redialActive() (tea.Model, tea.Cmd) { } eid, name := s.ElementID, s.Name tsg := m.tsgForElement(eid) - m.sessions.CloseActive() + if tsg == "" { + tsg = m.tenant.TSGID + } + 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 +} + +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 { cur := m.currentTab() - render := func(i int, label string) string { - return styleTabAt(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) } - parts := []string{render(0, fmt.Sprintf(" 1 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 { @@ -359,7 +363,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(ui.TabShell, i+1, i, fmt.Sprintf(" %s%s ", mark, s.Name))) } for i, t := range m.data { n := 1 + m.sessions.Count() + i @@ -367,7 +371,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(ui.TabData, n, i, fmt.Sprintf(" %s%s · data ", mark, t.name))) } bar := strings.Join(parts, " ") if n := m.sessions.DetachedCount(); n > 0 { @@ -399,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") } @@ -410,19 +412,18 @@ 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 · " + copyKeyHint + + " scrollback · alt+w detach · alt+k close · shift+pgup scroll" status := m.status switch { + case m.redialing == s.ElementID: case s.Err() != nil: - status = s.Err().Error() + 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 != "" { @@ -431,13 +432,12 @@ 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, or at a - // session that has stopped answering, 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 } @@ -449,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 @@ -460,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 @@ -479,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 bd8cfc9..444f512 100644 --- a/internal/tui/session_test.go +++ b/internal/tui/session_test.go @@ -144,8 +144,8 @@ func TestTabBarAlwaysHasElementsTab(t *testing.T) { m := pickModel(t) 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 +174,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() @@ -295,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() { @@ -360,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) } @@ -373,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 { @@ -402,18 +395,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")) @@ -660,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})) @@ -685,10 +664,15 @@ type recConn struct { mu sync.Mutex written []byte done chan struct{} + data chan []byte once sync.Once } -func newRecConn() *recConn { return &recConn{done: make(chan struct{})} } +func newRecConn() *recConn { + return &recConn{done: make(chan struct{}), data: make(chan []byte, 8)} +} + +func (c *recConn) feed(s string) { c.data <- []byte(s) } func (c *recConn) Read(ctx context.Context) (websocket.MessageType, []byte, error) { select { @@ -696,6 +680,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 } } @@ -1305,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 @@ -1374,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 { @@ -1396,13 +1378,34 @@ 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") + 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) + } + 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) } + + 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 +1422,129 @@ 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. if got.sessions.Find("1") == nil { t.Error("the healthy session was replaced by alt+r") } } + +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+"#") }) + + 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") + } + 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+"#") }) +} + +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") + } + } +} + +func TestAFailedRedialKeepsTheTab(t *testing.T) { + m := pickModel(t) + 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) + } +} + +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/settings.go b/internal/tui/settings.go index 3c2b5f9..a767ae9 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,25 @@ const ( focusContent ) -var setSections = []string{"General", "Batch", "Tenants", "Users", "Session", "Debug", "Reset"} +const ( + guardRow = iota + modeRow + jumpRow + zoneRow + refreshRow + generalRows +) + +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 +53,12 @@ const ( func (m tuiModel) sectionRows() int { switch m.setSection { case secGeneral: - return 5 + return generalRows + case secTheme: + if m.themeEdit { + return 0 + } + return len(ui.Themes()) + 1 case secBatch: return 4 case secTenants: @@ -49,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 @@ -115,6 +142,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: @@ -129,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" { @@ -140,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 @@ -181,23 +221,20 @@ func (m tuiModel) onGeneralKey(key string) (tea.Model, tea.Cmd) { if key != "enter" && key != " " && key != "space" { return m, nil } - if m.setCursor == 4 { + if m.setCursor == refreshRow { return m.refreshNow() } switch m.setCursor { - case 2: + case zoneRow: + 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() @@ -224,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 { @@ -362,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()) { @@ -371,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")) @@ -387,16 +421,16 @@ 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.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("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 { @@ -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")) @@ -421,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) } @@ -457,43 +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("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("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")) @@ -507,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 { @@ -514,7 +553,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.pick.on { + return "↑↓←→ move · +/- brightness · tab disc/chart · t type · enter keep · esc cancel" + } + return "" case secBatch: return "↑↓ row · enter change · tab back · esc back" case secTenants: @@ -527,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" } @@ -549,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") } @@ -565,12 +615,12 @@ 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()) 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") @@ -582,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 { @@ -592,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 { @@ -640,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)) @@ -648,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 { @@ -658,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 { @@ -687,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") @@ -706,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 @@ -717,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 393f443..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: "spinner", + secGeneral: "Commands", secTenants: "Acme Co", secUsers: "readonly", - secSession: "buffer cols", + secSession: "Buffer Cols", } for sec, want := range wants { m.setSection = sec @@ -111,7 +110,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,19 +271,19 @@ func TestRenderSettingsPanes(t *testing.T) { } func TestLabelsPadVisibleWidthNotEscapeBytes(t *testing.T) { - for _, s := range []string{"spinner", "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) } } @@ -436,7 +435,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 { @@ -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) } @@ -618,3 +617,75 @@ func TestGeometryFormTypesAValue(t *testing.T) { t.Errorf("status = %q, want a complaint about the value", got) } } + +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) + } +} + +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 a21b32c..6fe63ce 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,9 @@ func resolveVerb(word string) (string, bool) { return v, true } } + 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 +79,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 +105,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 +128,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 +163,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 +187,13 @@ func (m tuiModel) activateSlash() (tea.Model, tea.Cmd) { } return m, nil + case slashFreq: + 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 +202,19 @@ 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 +} + +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 +245,12 @@ func (m tuiModel) onSlashKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cm } return m, nil + case "ctrl+d": + 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 +274,7 @@ func (m tuiModel) slashWanted() int { case slashOff: return 0 case slashHelp: - return 11 + return 12 case slashDebug: return 8 default: @@ -256,6 +299,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") @@ -272,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() @@ -307,6 +351,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/spinners.go b/internal/tui/spinners.go index 89910e8..2956546 100644 --- a/internal/tui/spinners.go +++ b/internal/tui/spinners.go @@ -5,8 +5,7 @@ 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) +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 8c32bc2..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) @@ -21,10 +19,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() } }, @@ -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 new file mode 100644 index 0000000..53c34e0 --- /dev/null +++ b/internal/tui/themeedit.go @@ -0,0 +1,415 @@ +package tui + +import ( + "fmt" + "slices" + "strings" + + tea "charm.land/bubbletea/v2" + "tyrtop.com/iontk/internal/app" + "tyrtop.com/iontk/internal/ui" +) + +const spinnerRole = "spinner" + +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) } + +func (m tuiModel) openThemeEdit() (tea.Model, tea.Cmd) { + t, ok := m.themeAt(m.setCursor) + if !ok { + return m, nil + } + m.themeEdit = true + 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)] + + switch key { + case "esc", "q", "tab": + m.themeEdit = false + m.status = "" + return m.applyWorking() + + 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 + + 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", "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) + + 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 { + 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) { + 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" + 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}) +} + +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 + } + 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 + 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 +} + +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 { + 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.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("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" + } + sample := m.themeDraft.RoleStyle(role).Render("████") + out = append(out, cursor+ui.Label(ui.RoleLabel(role))+" "+sample+" "+ + ui.StyleElement.Render(shown)) + } + return append(out, "", ui.StyleDim.Render( + "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))) +} + +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", + "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 new file mode 100644 index 0000000..ff99e39 --- /dev/null +++ b/internal/tui/themeedit_test.go @@ -0,0 +1,506 @@ +package tui + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "tyrtop.com/iontk/internal/app" + "tyrtop.com/iontk/internal/ui" +) + +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 + 1 + } + } + + next, _ := m.onSettingsKey("e") + m = next.(tuiModel) + if !m.themeEdit { + t.Fatalf("e did not open the editor on %s", name) + } + return m +} + +func TestEditingAThemeWritesAnOverrideAndAppliesIt(t *testing.T) { + m := editorOn(t, "classic") + + before := m.themeDraft.OK + 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) + } + if m.status != "" { + t.Errorf("status = %q, want the edit to have gone through", m.status) + } + + 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") + } + + dir := app.ThemesPath(m.store) + path := filepath.Join(dir, ui.ThemeFileName("classic")) + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%s was not written: %v", ui.ThemeFileName("classic"), err) + } + if !strings.Contains(string(b), "classic") { + t.Errorf("the file does not hold the override:\n%s", b) + } + 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) + } + if _, ok := ui.ThemeByName("nord"); !ok { + t.Error("the override took the rest of the set with 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) + } +} + +func TestTypingAColourIsValidated(t *testing.T) { + m := editorOn(t, "nord") + next, _ := m.onSettingsKey("down") + m = next.(tuiModel) + next, _ = m.onSettingsKey("t") + 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) + } +} + +func TestAnEditedRoleCanGoBackToTheBuiltin(t *testing.T) { + m := editorOn(t, "classic") + original := m.themeDraft.OK + + next, _ := m.onSettingsKey("right") + m = next.(tuiModel) + if m.themeDraft.OK == original { + t.Fatal("nothing changed, so there is nothing to undo") + } + + 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) + } +} + +func TestTheEditorCyclesTheSpinnerShape(t *testing.T) { + m := editorOn(t, "classic") + roles := editRoles() + 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.onSettingsKey("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) + } +} + +func TestTheEditorNamesEveryRoleAndPreviewsTheDraft(t *testing.T) { + m := editorOn(t, "Ben's Theme") + + out := plain(m.viewSettings()) + for _, role := range ui.RoleNames() { + 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 still carries the help column (%q):\n%s", help, out) + } + } + 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) + } + if got := m.sectionRows(); got != 0 { + t.Errorf("sectionRows() = %d while editing, want 0", got) + } + if !strings.Contains(out, "enter pick") { + t.Errorf("the hint does not say how to change a colour:\n%s", out) + } + + 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, "enter select") { + 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()) +} + +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 9d59ac8..167b91c 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" @@ -51,6 +52,7 @@ const ( phaseDiscover phaseTenantPick phaseData + phaseMacros ) type pickMode int @@ -59,9 +61,9 @@ const ( modeShell pickMode = iota modeBatch modeData + 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 { @@ -70,6 +72,8 @@ func (p pickMode) String() string { return "batch" case modeData: return "rich data" + case modeMacro: + return "macro" } return "shell" } @@ -114,6 +118,11 @@ const ( formEditTenant formEditUser formGeometry + formFavourite + formMacro + formZone + formColour + formNewTheme ) const cacheTTL = time.Hour @@ -122,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 @@ -170,41 +171,31 @@ type tuiModel struct { phase tuiPhase status string - spinner spinner.Model - spinnerName string + spinner spinner.Model cacheState cacheState 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 @@ -225,23 +216,51 @@ type tuiModel struct { formOldTSG string formGeoRow int - setSection int - setFocus int - // The factory reset asks twice, and this is whether it has been asked once. - resetArmed bool + 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 + 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 + haCached map[string]string + haBulk string - all []elementRow - sites map[string]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 + listKeep []int + listWidths []int + listValues [][]string cmdInput textinput.Model @@ -260,53 +279,57 @@ type tuiModel struct { resTable table.Model resOpen bool resScroll int + 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. + sessions *ionterm.Manager + sesScroll int + connecting string dialing map[string]dialState dialReports chan dialProgressMsg - // 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 + redialing string + askRetry sessionTarget + askRetryNo bool - // The stats overlay, and whether its repaint is already running. stats bool statsTicking bool - 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 - // What to reopen once a tenant connection or sign-in finishes, the way - // resumeSessions does for shells. - resumeData []sessionTarget + cmds *config.CommandStore + cmdsErr string + + mode pickMode + views *dataview.Set + viewsErr string + data []dataTab + dataActive int + dataFilter textinput.Model + dataFilterOn bool + resumeData []sessionTarget userCursor int slash slashMode slashInput textinput.Model slashItems []string slashCursor int + freq []config.CommandUse + + favPick bool + favCursor int + + macroCursor int + macroFilter textinput.Model + macroFilterOn bool + formMacroName string + 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 { @@ -322,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)) @@ -332,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) { @@ -341,11 +364,9 @@ 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) + app.ApplyDisplaySettings(store, cfg) + + s := ui.NewSpinner(ui.CurrentTheme().Spinner) fi := textinput.New() fi.Prompt = "/" @@ -361,12 +382,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, @@ -384,29 +405,33 @@ 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 - // 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 + + m.dataFilter = newDataFilter() + 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() { - // 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) @@ -419,23 +444,58 @@ func newTUIModel(ctx context.Context, store *config.Store, cfg *config.Config) t return m } +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: 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), ) + t.SetStyles(tableStyles()) + return t +} + +// 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 { + return + } + n = max(0, min(n, len(rows)-1)) + prev := m.table.Cursor() + if n >= prev { + m.table.MoveDown(n - prev) + m.repaintCursorRows(prev) + 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 + m.repaintCursorRows(prev) +} + +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 { @@ -448,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" @@ -466,6 +525,8 @@ func (m tuiModel) refreshNow() (tea.Model, tea.Cmd) { } m.flags = f m.status = "" + m.haFailed, m.haErr = 0, "" + m.haTotal, m.haDone, m.haBulk = 0, 0, "" return m.refreshActive() } @@ -482,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} @@ -514,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 } @@ -534,6 +582,33 @@ 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: + ui.SetProfile(msg.Profile) + if ui.ShouldQueryDepth() { + 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 @@ -555,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" } @@ -614,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() @@ -627,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 @@ -647,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) @@ -669,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) @@ -709,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()) } @@ -718,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) @@ -737,6 +799,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 +817,11 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.sites[k] = v } m.mergeTenantElements(msg.tsg, msg.tenant, msg.elements) + 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) + } } if m.pendingTenants == 0 { @@ -768,12 +836,13 @@ 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() { return m, nil } + m.spinTick++ var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd @@ -789,9 +858,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: @@ -804,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 @@ -816,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 @@ -845,17 +908,21 @@ 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) + case compareDoneMsg: + return m.onCompareDone(msg) + + case commandsSavedMsg: + return m.onCommandsSaved(msg) + + case haStatesMsg: + return m.onHAStates(msg) + + case haTickMsg: + return m.onHATick(msg.tsg) + case tea.KeyPressMsg: return m.onKey(msg) @@ -870,6 +937,11 @@ func (m tuiModel) forwardToInput(msg tea.Msg) (tea.Model, tea.Cmd) { if len(m.form) == 0 || m.formCursor >= len(m.form) { return m, nil } + 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 @@ -879,17 +951,30 @@ 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: + 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 + 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 @@ -915,15 +1000,12 @@ 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: - return m.connectingAny() + return m.connectingAny() || m.awaitingDevice() case phaseData: - // Only while a pane is actually reading, or it repaints for nothing. for _, t := range m.data { - if t.loading() { + if t.loading() || t.cmp.loading { return true } } @@ -951,82 +1033,304 @@ 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 { +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)) } -// 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 } +func (m tuiModel) showHAColumn() bool { + for _, r := range m.all { + if r.ClusterID() != "" { + return true + } + } + return false +} + +func (m tuiModel) showSerialColumn() bool { + for _, r := range m.all { + if r.Serial != "" { + return true + } + } + return false +} + +type elementColumn struct { + title string + min int + max int + flex bool +} + +var elementColumns = []elementColumn{ + {title: "", min: 2, max: 2}, + {title: "", min: 3, max: 3}, + {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}, +} + +const ( + colCaret = iota + colStatus + colName + colSite + colModel + colSerial + colState + colHA + colTenant +) + +const colGap = 2 + +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])) + } + } + w[i] = min(w[i], c.max) + } + return shaveColumns(cols, w, avail) +} + +func shaveColumns(cols []elementColumn, w []int, avail int) []int { + total := func() int { + n := 0 + for _, x := range w { + n += x + colGap + } + 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 at < 0 { + break + } + w[at]-- + } + return w +} + func (m *tuiModel) rebuildRows() { - rows := make([]table.Row, 0, len(m.matches)) tenants := m.showTenantColumn() + ha := m.showHAColumn() + serials := m.showSerialColumn() + + keep := []int{colCaret, colStatus, colName, colSite, colModel} + if serials { + keep = append(keep, colSerial) + } + keep = append(keep, colState) + if ha { + keep = append(keep, colHA) + } + if tenants { + 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 := make([][]string, 0, len(m.matches)) for _, i := range m.matches { el := m.all[i] - mark := " " - if m.selected[el.key()] { - mark = "x" - } - name := el.label() - state := "offline" - if el.Connected { - state = "connected" - } - row := table.Row{ - mark, - m.rowStatus(el), - ui.Truncate(name, 26), - ui.Truncate(m.siteName(el), 22), - ui.Truncate(el.Model, 11), - state, + 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 + } } - if tenants { - row = append(row, ui.Truncate(el.Tenant, 20)) + 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)) + + 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) } - rows = append(rows, row) } - 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}, + 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 } - width := 86 - if tenants { - cols = append(cols, table.Column{Title: "tenant", Width: 20}) - width = 108 - } - // 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()) } +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 colStatus: + glyph, _ := m.rowStatusOf(el) + return glyph + case colName: + if m.selected[el.key()] { + return selectedMark + el.label() + } + 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 { + return word + "*" + } + return word + case colTenant: + return el.Tenant + } + return "" +} + +func (m *tuiModel) listRow(n int) table.Row { + vals := m.listValues[n] + el := m.all[m.matches[n]] + here := n == m.table.Cursor() + + 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: + v = plainIf(here, v) + case colState: + switch { + case here: + case el.Connected: + v = paint(v) + default: + v = ui.StyleErr.Render(v) + } + default: + v = paint(v) + } + row = append(row, v) + } + return row +} + +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 +} + +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) +} + +func (m *tuiModel) repaintCursorRows(prev int) { + rows := m.table.Rows() + if len(rows) != len(m.listValues) { + return + } + 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()}) @@ -1074,12 +1378,14 @@ 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() } + if m.askMacro != "" { + return m.onMacroWarnKey(key) + } + if m.askRetry.eid != "" { return m.onRetryKey(key) } @@ -1088,7 +1394,14 @@ 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) case phaseMenu: return m.onMenuKey(key) case phaseSwitch: @@ -1109,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" @@ -1136,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 { @@ -1157,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 @@ -1166,33 +1476,44 @@ 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", "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()) + 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": - // 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 { - 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": @@ -1201,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 @@ -1211,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) @@ -1223,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 } @@ -1247,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 } @@ -1260,8 +1571,6 @@ func (m tuiModel) onPickKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd 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 @@ -1269,8 +1578,9 @@ 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() + m.armedMacro = "" m.status = "" return m, nil @@ -1297,13 +1607,9 @@ 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 { + if m.mode == modeBatch || m.mode == modeMacro { m.selected[el.key()] = true m.rebuildRows() return m, nil @@ -1314,28 +1620,30 @@ 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 { + if key == "backspace" && m.filter.Value() == "" { + m.filterOn = false + m.filter.Blur() + m.applyFilter() + return m, nil + } var cmd tea.Cmd m.filter, cmd = m.filter.Update(msg) m.applyFilter() - return m, cmd + return m, tea.Batch(cmd, m.askVisibleHA()) } if key == "alt+t" { 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 to the menu; 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 { @@ -1344,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+]": @@ -1358,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 { @@ -1381,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 { @@ -1424,6 +1723,9 @@ func (m tuiModel) onPickTargets(targets []sessionTarget) (tea.Model, tea.Cmd) { if m.mode == modeShell { return m.openSessions(targets) } + if m.mode == modeMacro { + return m.runArmedMacro(m.rowsFor(targets)) + } m.status = "" m.phase = phaseCommand @@ -1460,7 +1762,17 @@ 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) { +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 +} + +func (m tuiModel) selectedRows() ([]elementRow, []string) { var picked []elementRow var ids []string for _, el := range m.all { @@ -1469,7 +1781,15 @@ 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) +} +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() @@ -1477,9 +1797,6 @@ func (m tuiModel) startRun(cmds []string) (tea.Model, tea.Cmd) { } } - // 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 == "" { @@ -1515,6 +1832,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 @@ -1527,7 +1845,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() { @@ -1558,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-- } @@ -1604,13 +1922,19 @@ 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() + v.BackgroundColor, v.ForegroundColor = frameColours() return v } @@ -1620,10 +1944,10 @@ 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") + m.selectedNote() + "\n") + b.WriteString(m.refreshNote() + "\n") if m.filterOn || m.filter.Value() != "" { line := m.filter.View() @@ -1656,20 +1980,22 @@ 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. clear := "alt+c clear" 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/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 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 - // applying the query, so the hint says which. enter := "enter apply" if len(m.matches) == 1 { enter = "enter open" @@ -1752,3 +2078,10 @@ func (m tuiModel) viewRunning() string { return b.String() } + +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 bc5031e..f1b7fe6 100644 --- a/internal/tui/tuimenu.go +++ b/internal/tui/tuimenu.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "slices" "strconv" "strings" "time" @@ -21,6 +22,7 @@ const ( actSignIn actRefreshToken actSwitch + actMacros actSettings actQuit ) @@ -36,27 +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{"Settings", actSettings}, menuEntry{"Quit", actQuit}, ) @@ -79,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)) @@ -112,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 { @@ -135,6 +128,13 @@ func (m tuiModel) onMenuKey(key string) (tea.Model, tea.Cmd) { } switch entries[m.menuCursor].action { case actElements: + 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 @@ -146,13 +146,13 @@ 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 } return m.startDiscover() + case actMacros: + return m.openMacros() case actSettings: m.setCursor = 0 m.phase = phaseSettings @@ -191,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 @@ -199,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 @@ -222,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() } @@ -258,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{ @@ -276,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 @@ -292,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 @@ -302,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 @@ -332,6 +329,9 @@ func (m tuiModel) onFormKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd return m, nil case "tab", "down": + 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 +343,11 @@ func (m tuiModel) onFormKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd case "enter": return m.submitForm() + + case "backspace": + if m.formKind == formMacro && m.formCursor >= 1 && len(m.form) > 2 && m.formValue(m.formCursor) == "" { + return m.removeMacroCommand() + } } var cmd tea.Cmd @@ -359,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 { @@ -412,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 @@ -436,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) } @@ -551,6 +551,21 @@ 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 formNewTheme: + return m.submitNewTheme() + + case formFavourite: + return m.submitFavourite() + + case formMacro: + return m.submitMacro() + case formGeometry: n, err := strconv.Atoi(m.formValue(0)) if err != nil { @@ -603,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} @@ -622,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 } @@ -631,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() } @@ -650,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") } @@ -664,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() @@ -690,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() @@ -751,17 +762,23 @@ func (m tuiModel) viewForm() string { b.WriteString(" " + label + f.input.View() + "\n") } + 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() } -// 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 "" @@ -789,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 @@ -960,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" @@ -984,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)) @@ -1035,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 99c3df0..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 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"} + 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} @@ -328,10 +321,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()) } @@ -472,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) @@ -488,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 { @@ -501,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 3ed2c06..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)) @@ -30,12 +25,10 @@ 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()) - // 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 c1f2f0c..8f648d6 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,36 @@ func SpinnerName(name string) string { return spinnerChoices[spinnerIndex(name)].name } -func NextSpinnerName(name string) string { - return spinnerChoices[(spinnerIndex(name)+1)%len(spinnerChoices)].name +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 } + +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[((at%len(frames))+len(frames))%len(frames)] +} diff --git a/internal/ui/spinners_test.go b/internal/ui/spinners_test.go index ba9e820..2ba291b 100644 --- a/internal/ui/spinners_test.go +++ b/internal/ui/spinners_test.go @@ -2,20 +2,29 @@ package ui import "testing" -func TestSpinnerCycle(t *testing.T) { +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 d570aa5..0000000 --- a/internal/ui/styles.go +++ /dev/null @@ -1,17 +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")) - - MarkCheck = StyleOK.SetString("✓") - MarkCross = StyleErr.SetString("✗") -) diff --git a/internal/ui/text.go b/internal/ui/text.go index a2d0d27..c1ca98d 100644 --- a/internal/ui/text.go +++ b/internal/ui/text.go @@ -3,19 +3,19 @@ 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) } +func Plain(s string) string { return ansi.Strip(s) } + func Truncate(s string, n int) string { if n <= 0 { return "" @@ -26,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: @@ -41,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 new file mode 100644 index 0000000..5b7d7b8 --- /dev/null +++ b/internal/ui/theme.go @@ -0,0 +1,968 @@ +package ui + +import ( + "embed" + "fmt" + "image/color" + "os" + "path/filepath" + "regexp" + "slices" + "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" + 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 +} + +type Theme struct { + Name string `yaml:"name"` + 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 + 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 + 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 +} + +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 + loadErr string +) + +type themeSet struct { + Themes []Theme `yaml:"themes"` +} + +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 { + 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 +} + +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 + } + files, err := userThemeFiles(path) + if err != nil { + loadErr = fmt.Sprintf("read %s: %s", filepath.Base(path), err) + return fmt.Errorf("read %s: %w", path, err) + } + + 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 + } + 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))) + 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 RoleNames() { + if err := checkColour(t.Role(role)); err != nil { + return fmt.Errorf("theme %s: %s: %w", t.Name, role, 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(), ", ")) + } + return nil +} + +func CheckColour(spec string) error { return checkColour(spec) } + +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)) +} + +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{} + 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)) + } + 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 +} + +func (t Theme) Deep() bool { + 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 + } + 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() +} + +var ( + current = fallback + styles = fallback.Styles() + StyleOK = styles.OK + StyleErr = styles.Err + StyleWarn = styles.Warn + StyleCmd = styles.Cmd + StyleElement = styles.Element + StyleDim = styles.Dim + StyleHot = styles.Hot + StyleRow = styles.Row + StyleGhost = styles.Ghost + StyleBold = lipgloss.NewStyle().Bold(true) + StyleDivider = styles.Divider + StyleSpinner = styles.Spinner + + MarkCheck = StyleOK.SetString("✓") + MarkCross = StyleErr.SetString("✗") +) + +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 } + +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 } + +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: + return "truecolor" + case colorprofile.ANSI256: + return "256 colours" + case colorprofile.ANSI: + return "16 colours" + case colorprofile.ASCII, colorprofile.NoTTY: + return "no colour" + } + return "" +} + +func ShouldQueryDepth() bool { + return current.Deep() && profile != colorprofile.Unknown && profile < colorprofile.TrueColor +} + +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", "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 { + 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 "background": + return t.Bg + case "other": + return t.Fg + case "spinner_color": + return t.SpinnerColor + } + return "" +} + +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 "background": + t.Bg = spec + case "other": + t.Fg = spec + case "spinner_color": + t.SpinnerColor = spec + default: + return fmt.Errorf("no colour called %q", name) + } + return nil +} + +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 +} + +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)] +} + +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 + } + + if !pathIsFile(path) { + return saveThemeFile(path, t) + } + + set := themeSet{} + if b, err := os.ReadFile(path); err == nil { + existing, perr := ParseThemes(b) + if perr != nil { + 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" + 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) +} + +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) 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 new file mode 100644 index 0000000..0f129a0 --- /dev/null +++ b/internal/ui/theme_test.go @@ -0,0 +1,693 @@ +package ui + +import ( + "charm.land/lipgloss/v2" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/charmbracelet/colorprofile" +) + +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 + + 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) + } + } +} + +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 + } + 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) + } + } + } +} + +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) + } + } +} + +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") + } + 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()) + } +} + +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") + } + }) + } +} + +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) + } + if !UseTheme("BEN'S THEME") { + t.Error("UseTheme() is case-sensitive, which a hand-written config will not be") + } +} + +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) + if got := MarkCheck.String(); !strings.Contains(got, "163") { + t.Errorf("MarkCheck = %q, want nord's green", got) + } +} + +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) + } + } +} + +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(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") + } + 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 new file mode 100644 index 0000000..acdcbf1 --- /dev/null +++ b/internal/ui/themes.yaml @@ -0,0 +1,237 @@ +# 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 +# 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 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. +# "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" + bg: "#000000" + fg: "#e4e4e4" + 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" + bg: "#2e3440" + fg: "#d8dee9" + 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" + bg: "#33302a" + fg: "#f5deb3" + 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" + bg: "#333333" + fg: "#ffffff" + 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" + # 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"] + + - 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" + bg: "#262626" + fg: "#c5c8c6" + 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" + bg: "#000000" + fg: "#ffffff" + 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" + bg: "#1c1c1c" + fg: "#bcbcbc" + 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" + # 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"]