├── .github └── workflows │ └── goreleaser.yml ├── .gitignore ├── .goreleaser.yaml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd └── tui │ └── main.go ├── go.mod ├── go.sum ├── images ├── demo.png └── wishlist_example.png ├── messages └── tui.go ├── tui ├── internal │ ├── bubbles │ │ ├── about │ │ │ ├── about.go │ │ │ ├── help.go │ │ │ └── utils.go │ │ ├── accounts │ │ │ └── accounts.go │ │ ├── configs │ │ │ └── configs.go │ │ ├── explorer │ │ │ ├── blocks.go │ │ │ ├── explorer.go │ │ │ ├── transaction.go │ │ │ └── transactions.go │ │ ├── footer │ │ │ └── footer.go │ │ ├── status │ │ │ └── status.go │ │ ├── tabs │ │ │ └── tabs.go │ │ └── template │ │ │ └── template.go │ ├── constants │ │ └── keymap.go │ ├── model │ │ ├── init.go │ │ ├── model.go │ │ ├── update.go │ │ └── view.go │ └── style │ │ └── style.go └── tui.go └── version └── strings.go /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v0.**' 7 | 8 | permissions: 9 | contents: write 10 | # packages: write 11 | # issues: write 12 | 13 | jobs: 14 | goreleaser: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3.5.3 18 | with: 19 | fetch-depth: 0 20 | - run: git fetch --force --tags 21 | 22 | - name: go dependency 23 | uses: actions/setup-go@v4.0.1 24 | with: 25 | go-version-file: 'go.mod' 26 | 27 | - name: Setup QEMU (docker multi-arch dependency) 28 | uses: docker/setup-qemu-action@v2 29 | 30 | - name: Setup Docker Buildx (docker multi-arch dependency) 31 | uses: docker/setup-buildx-action@v2 32 | 33 | - name: dockerhub-login 34 | uses: docker/login-action@v2 35 | with: 36 | username: ${{ secrets.DOCKERHUB_USERNAME }} 37 | password: ${{ secrets.DOCKERHUB_TOKEN }} 38 | 39 | - uses: goreleaser/goreleaser-action@v4 40 | with: 41 | distribution: goreleaser 42 | version: latest 43 | args: release --clean 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | nodeui 2 | .idea 3 | bin/ 4 | dist/ 5 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | project_name: nodeui 2 | env: 3 | - DOCKER_NAME=algorand/nodeui 4 | 5 | before: 6 | hooks: 7 | # You may remove this if you don't use go modules. 8 | - go mod tidy 9 | # you may remove this if you don't need go generate 10 | - go generate ./... 11 | 12 | universal_binaries: 13 | - replace: true 14 | 15 | builds: 16 | - main: cmd/tui/main.go 17 | env: 18 | - CGO_ENABLED=0 19 | goos: 20 | - linux 21 | - windows 22 | - darwin 23 | goarch: 24 | - amd64 25 | - arm64 26 | ldflags: > 27 | -s -w 28 | -X github.com/algorand/node-ui/version.Hash={{.FullCommit}} 29 | -X github.com/algorand/node-ui/version.ShortHash={{.ShortCommit}} 30 | -X github.com/algorand/node-ui/version.CompileTime={{.Timestamp}} 31 | -X github.com/algorand/node-ui/version.ReleaseVersion={{.Version}} 32 | 33 | dockers: 34 | - use: buildx 35 | goos: linux 36 | goarch: amd64 37 | image_templates: 38 | - "{{ .Env.DOCKER_NAME }}:latest{{ if .IsSnapshot }}-snapshot{{ end }}-amd64" 39 | - "{{ .Env.DOCKER_NAME }}:{{ .Version }}-amd64" 40 | build_flag_templates: 41 | - --platform=linux/amd64 42 | - --label=org.opencontainers.image.title={{ .ProjectName }} 43 | - --label=org.opencontainers.image.version={{ .Version }} 44 | - --label=org.opencontainers.image.created={{ .Date }} 45 | - --label=org.opencontainers.image.revision={{ .FullCommit }} 46 | - --label=org.opencontainers.image.licenses=MIT 47 | - use: buildx 48 | goos: linux 49 | goarch: arm64 50 | image_templates: 51 | - "{{ .Env.DOCKER_NAME }}:latest{{ if .IsSnapshot }}-snapshot{{ end }}-arm64" 52 | - "{{ .Env.DOCKER_NAME }}:{{ .Version }}-arm64" 53 | build_flag_templates: 54 | - --platform=linux/arm64 55 | - --label=org.opencontainers.image.title={{ .ProjectName }} 56 | - --label=org.opencontainers.image.version={{ .Version }} 57 | - --label=org.opencontainers.image.created={{ .Date }} 58 | - --label=org.opencontainers.image.revision={{ .FullCommit }} 59 | - --label=org.opencontainers.image.licenses=MIT 60 | 61 | # automatically select amd64/arm64 when using image. 62 | docker_manifests: 63 | - name_template: "{{ .Env.DOCKER_NAME }}:{{ .Version }}" 64 | image_templates: 65 | - "{{ .Env.DOCKER_NAME }}:{{ .Version }}-amd64" 66 | - "{{ .Env.DOCKER_NAME }}:{{ .Version }}-arm64" 67 | - name_template: "{{ .Env.DOCKER_NAME }}:latest{{ if .IsSnapshot }}-snapshot{{ end }}" 68 | image_templates: 69 | - "{{ .Env.DOCKER_NAME }}:latest{{ if .IsSnapshot }}-snapshot{{ end }}-amd64" 70 | - "{{ .Env.DOCKER_NAME }}:latest{{ if .IsSnapshot }}-snapshot{{ end }}-arm64" 71 | 72 | archives: 73 | - name_template: >- 74 | {{ .ProjectName }}_ 75 | {{- title .Os }}_ 76 | {{- if eq .Arch "amd64" }}x86_64 77 | {{- else if eq .Arch "386" }}i386 78 | {{- else if eq .Arch "windows" }}Windows 79 | {{- else if eq .Arch "linux" }}Linux 80 | {{- else if eq .Arch "darwin" }}Darwin 81 | {{- else }}{{ .Arch }}{{ end }} 82 | checksum: 83 | name_template: 'checksums.txt' 84 | snapshot: 85 | name_template: "{{ incpatch .Version }}-next" 86 | changelog: 87 | sort: asc 88 | filters: 89 | exclude: 90 | - '^docs:' 91 | - '^test:' 92 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build this Dockerfile with goreleaser. 2 | # The binary must be present at /conduit 3 | FROM debian:bullseye-slim 4 | 5 | RUN groupadd --gid=999 --system algorand && \ 6 | useradd --uid=999 --no-log-init --create-home --system --gid algorand algorand && \ 7 | apt-get update && \ 8 | apt-get install -y --no-install-recommends ca-certificates && \ 9 | update-ca-certificates && \ 10 | apt-get clean && \ 11 | rm -rf /var/lib/apt/lists/* 12 | 13 | USER algorand 14 | 15 | COPY nodeui /usr/local/bin/nodeui 16 | 17 | ENTRYPOINT ["nodeui"] 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Algorand 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build 2 | 3 | MAINFILE := cmd/tui/main.go 4 | BINNAME := nodeui 5 | 6 | GOLDFLAGS += -X github.com/algorand/node-ui/version.Hash=$(shell git log -n 1 --pretty="%H") 7 | GOLDFLAGS += -X github.com/algorand/node-ui/version.ShortHash=$(shell git log -n 1 --pretty="%h") 8 | GOLDFLAGS += -X github.com/algorand/node-ui/version.CompileTime=$(shell date -u +%Y-%m-%dT%H:%M:%S%z) 9 | GOLDFLAGS += -X "github.com/algorand/node-ui/version.ReleaseVersion=Dev Build" 10 | 11 | build: 12 | go build -o $(BINNAME) -ldflags='${GOLDFLAGS}' $(MAINFILE) 13 | 14 | fmt: 15 | go fmt ./... 16 | 17 | lint: 18 | golint -set_exit_status ./... 19 | go vet ./... 20 | golangci-lint run 21 | 22 | dep: 23 | go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.46.2 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # **DISCLAIMER** 2 | This project is not supported, and is subject to change. There are no compatibility guarantees. It was developed during a hackathon and isn't perfect. If you see any strange artifacts, try resizing the window to help the program re-orient itself. 3 | 4 | # Algod Node UI 5 | 6 | Terminal UI for remote Algorand node management. 7 | 8 | ![Example Screenshot](images/demo.png) 9 | 10 | # Install 11 | ## Download 12 | See the GitHub releases and download the binary for your platform. 13 | 14 | ## Source 15 | Use go1.20.5 or later and build with `make`. 16 | 17 | # Usage 18 | With no options, the UI will be displayed instead of starting a service. 19 | 20 | ## Local Algod 21 | ``` 22 | ~$ ALGORAND_DATA=path/to/data/dir ./nodeui 23 | ``` 24 | ## Remote Algod 25 | ``` 26 | ~$ ./nodeui -t -u http:// 27 | ``` 28 | 29 | # Run as a service 30 | 31 | The preferred method for running the node UI is as a service running alongside algod. By passing a port using `-p` or `--tui-port` an SSH server is started and can host the UI for multiple clients. 32 | 33 | A tool like [wishlist](https://github.com/charmbracelet/wishlist#wishlist) can be used to interactively select between multiple node deployments. In the screenshot below you can see a sample ssh config file, and the UI wishlist provides to select which nodeui to connect to. 34 | 35 | ![Wishlist Example](images/wishlist_example.png) 36 | 37 | # Features 38 | 39 | ## Status 40 | 41 | Realtime node status, including detailed fast-catchup progress. 42 | 43 | ## Block Explorer 44 | 45 | Display realtime block data, drill down into a block to see all of the transactions and transaction details. 46 | 47 | ## Utilities 48 | 49 | Start a fast catchup with the press of a key, and more (if you build it)! 50 | 51 | ## Built in documentation 52 | 53 | [Kind of](tui/internal/bubbles/about/help.go). 54 | 55 | # Architecture 56 | 57 | Built using [Bubble Tea](https://github.com/charmbracelet/bubbletea). Node information is collected from the Algod REST API using the [go SDK](https://github.com/algorand/go-algorand-sdk), and from reading files on disk. 58 | 59 | Each box on the screen is a "bubble", they manage state independently with an event update loop. Events are passed to each bubble, which have the option of consuming the event and/or passing it along to any nested bubbles. When processing the event, they may optionally add follow-up tasks which the scheduling engine would execute asynchronously. Follow-up tasks may optionally create more events which would be processed in turn using the same mechanism. 60 | 61 | When displaying the UI, each bubble is asked to renders itself and they are finally joined together for final rendering using [lipgloss](https://github.com/charmbracelet/lipgloss). Web development aficionado may recognize this pattern as [The Elm Architecture](https://guide.elm-lang.org/architecture/). 62 | 63 | There are some quirks to this approach. The main one is that bubbletea is a rendering engine, NOT a window manager. This means that things like window heights and widths must be self-managed. Any mismanagement leads to very strange artifacts as the rendering engine tries to fit too many, or too few lines to a fixed sized terminal. 64 | 65 | # Contributing 66 | 67 | Contributions are welcome! There are no plans to actively maintain this project, so if you find it useful please consider helping out. 68 | 69 | # How to create a new release 70 | 71 | 1. Create a tag: `git tag -a v_._._ -m "v_._._" && git push origin v_._._` 72 | 2. Push the tag. 73 | 3. CI should create a release, attach it to GitHub and publish images to docker hub. 74 | -------------------------------------------------------------------------------- /cmd/tui/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/urfave/cli/v3" 11 | 12 | "github.com/algorand/go-algorand-sdk/v2/client/v2/algod" 13 | "github.com/algorand/go-algorand-sdk/v2/types" 14 | 15 | "github.com/algorand/node-ui/messages" 16 | "github.com/algorand/node-ui/tui" 17 | "github.com/algorand/node-ui/version" 18 | ) 19 | 20 | func main() { 21 | err := makeCommand().Run(context.Background(), os.Args) 22 | if err != nil { 23 | fmt.Fprintf(os.Stderr, "Problem running command: %s\n", err.Error()) 24 | } 25 | } 26 | 27 | // TODO "r" to set the refresh rate 28 | type arguments struct { 29 | tuiPort uint64 30 | algodURL string 31 | algodToken string 32 | algodDataDir string 33 | addressWatchList []string 34 | versionFlag bool 35 | } 36 | 37 | func run(args arguments) { 38 | if args.versionFlag { 39 | fmt.Println(version.LongVersion()) 40 | os.Exit(0) 41 | } 42 | request := getRequestorOrExit(args.algodDataDir, args.algodURL, args.algodToken) 43 | addresses := getAddressesOrExit(args.addressWatchList) 44 | tui.Start(args.tuiPort, request, addresses) 45 | } 46 | 47 | func makeCommand() *cli.Command { 48 | var args arguments 49 | return &cli.Command{ 50 | Name: "node-ui", 51 | Usage: "Launch the Algorand Node UI.", 52 | Flags: []cli.Flag{ 53 | &cli.Uint64Flag{ 54 | Name: "tui-port", 55 | Aliases: []string{"p"}, 56 | Usage: "Port address to host TUI from, set to 0 to run directly.", 57 | Value: 0, 58 | Sources: cli.EnvVars("TUI_PORT"), 59 | Destination: &args.tuiPort, 60 | }, 61 | &cli.StringFlag{ 62 | Name: "algod-url", 63 | Aliases: []string{"u"}, 64 | Usage: "Algod URL and port to monitor, formatted like localhost:1234.", 65 | Value: "", 66 | Sources: cli.EnvVars("ALGOD_URL"), 67 | Destination: &args.algodURL, 68 | }, 69 | &cli.StringFlag{ 70 | Name: "algod-token", 71 | Aliases: []string{"t"}, 72 | Usage: "Algod REST API token.", 73 | Value: "", 74 | Sources: cli.EnvVars("ALGOD_TOKEN"), 75 | Destination: &args.algodToken, 76 | }, 77 | &cli.StringFlag{ 78 | Name: "algod-data-dir", 79 | Aliases: []string{"d"}, 80 | Usage: "Path to Algorand data directory.", 81 | Value: "", 82 | Sources: cli.EnvVars("ALGORAND_DATA"), 83 | Destination: &args.algodDataDir, 84 | }, 85 | &cli.StringSliceFlag{ 86 | Name: "watch-list", 87 | Aliases: []string{"w"}, 88 | Usage: "Account addresses to watch in the accounts tab, may provide more than once to watch multiple accounts. Use comma separated values if providing more than one account with an environment variable.", 89 | Value: nil, 90 | Sources: cli.EnvVars("WATCH_LIST"), 91 | Destination: &args.addressWatchList, 92 | }, 93 | &cli.BoolFlag{ 94 | Name: "version", 95 | Aliases: []string{"v"}, 96 | Usage: "Print version information and exit.", 97 | Value: false, 98 | Destination: &args.versionFlag, 99 | }, 100 | }, 101 | Action: func(c *cli.Context) error { 102 | run(args) 103 | return nil 104 | }, 105 | } 106 | } 107 | 108 | func getRequestorOrExit(algodDataDir, url, token string) *messages.Requestor { 109 | // Initialize from -d, ALGORAND_DATA, or provided URL/Token 110 | 111 | if algodDataDir != "" && (url != "" || token != "") { 112 | fmt.Fprintln(os.Stderr, "Do not use -u/-t with -d.") 113 | os.Exit(1) 114 | } 115 | 116 | // If url/token are missing, attempt to use environment variable. 117 | if url == "" && token == "" { 118 | if algodDataDir == "" { 119 | algodDataDir = os.Getenv("ALGORAND_DATA") 120 | if algodDataDir != "" { 121 | fmt.Println("Using ALGORAND_DATA environment variable.") 122 | } 123 | } 124 | 125 | if algodDataDir == "" { 126 | fmt.Fprintln(os.Stderr, "Algod is not available.\nMust provide url and token with -u/-t or a data directory with -d or the ALGORAND_DATA environment variable.") 127 | os.Exit(1) 128 | } 129 | 130 | netpath := filepath.Join(algodDataDir, "algod.net") 131 | tokenpath := filepath.Join(algodDataDir, "algod.token") 132 | 133 | var netaddrbytes []byte 134 | netaddrbytes, err := os.ReadFile(netpath) 135 | if err != nil { 136 | fmt.Fprintf(os.Stderr, "Unable to read URL from file (%s): %s\n", netpath, err.Error()) 137 | os.Exit(1) 138 | } 139 | url = strings.TrimSpace(string(netaddrbytes)) 140 | tokenBytes, err := os.ReadFile(tokenpath) 141 | if err != nil { 142 | fmt.Fprintf(os.Stderr, "Unable to read Token from file (%s): %s\n", tokenpath, err.Error()) 143 | os.Exit(1) 144 | } 145 | token = string(tokenBytes) 146 | } 147 | 148 | if !strings.HasPrefix(url, "http") { 149 | url = "http://" + url 150 | } 151 | 152 | if url == "" || token == "" { 153 | fmt.Fprintln(os.Stderr, "Must provide a way to get the algod REST API.") 154 | os.Exit(1) 155 | } 156 | 157 | client, err := algod.MakeClient(url, token) 158 | if err != nil { 159 | fmt.Fprintf(os.Stderr, "Problem creating client connection: %s\n", err.Error()) 160 | os.Exit(1) 161 | } 162 | 163 | return messages.MakeRequestor(client, algodDataDir) 164 | } 165 | 166 | func getAddressesOrExit(addrs []string) (result []types.Address) { 167 | failed := false 168 | for _, addr := range addrs { 169 | converted, err := types.DecodeAddress(addr) 170 | if err != nil { 171 | fmt.Fprintf(os.Stderr, "Failed to decode address '%s': %s\n", addr, err.Error()) 172 | failed = true 173 | } 174 | result = append(result, converted) 175 | } 176 | 177 | if failed { 178 | os.Exit(1) 179 | } 180 | 181 | return result 182 | } 183 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/algorand/node-ui 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/algorand/go-algorand-sdk/v2 v2.2.0 7 | github.com/calyptia/go-bubble-table v0.2.1 8 | github.com/charmbracelet/bubbles v0.16.1 9 | github.com/charmbracelet/bubbletea v0.24.2 10 | github.com/charmbracelet/glamour v0.6.0 11 | github.com/charmbracelet/lipgloss v0.7.1 12 | github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103 13 | github.com/charmbracelet/wish v1.1.1 14 | github.com/muesli/reflow v0.3.0 15 | github.com/urfave/cli/v3 v3.0.0-alpha4 16 | ) 17 | 18 | require ( 19 | github.com/alecthomas/chroma v0.10.0 // indirect 20 | github.com/algorand/go-codec/codec v1.1.10 // indirect 21 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 22 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 23 | github.com/aymerick/douceur v0.2.0 // indirect 24 | github.com/caarlos0/sshmarshal v0.1.0 // indirect 25 | github.com/charmbracelet/harmonica v0.2.0 // indirect 26 | github.com/charmbracelet/keygen v0.4.2 // indirect 27 | github.com/charmbracelet/log v0.2.1 // indirect 28 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect 29 | github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect 30 | github.com/dlclark/regexp2 v1.4.0 // indirect 31 | github.com/go-logfmt/logfmt v0.6.0 // indirect 32 | github.com/google/go-querystring v1.0.0 // indirect 33 | github.com/gorilla/css v1.0.0 // indirect 34 | github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc // indirect 35 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 36 | github.com/lunixbochs/vtclean v1.0.0 // indirect 37 | github.com/mattn/go-isatty v0.0.18 // indirect 38 | github.com/mattn/go-localereader v0.0.1 // indirect 39 | github.com/mattn/go-runewidth v0.0.14 // indirect 40 | github.com/microcosm-cc/bluemonday v1.0.21 // indirect 41 | github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect 42 | github.com/muesli/cancelreader v0.2.2 // indirect 43 | github.com/muesli/termenv v0.15.1 // indirect 44 | github.com/olekukonko/tablewriter v0.0.5 // indirect 45 | github.com/rivo/uniseg v0.2.0 // indirect 46 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 47 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect 48 | github.com/yuin/goldmark v1.5.2 // indirect 49 | github.com/yuin/goldmark-emoji v1.0.1 // indirect 50 | golang.org/x/crypto v0.8.0 // indirect 51 | golang.org/x/net v0.9.0 // indirect 52 | golang.org/x/sync v0.1.0 // indirect 53 | golang.org/x/sys v0.7.0 // indirect 54 | golang.org/x/term v0.7.0 // indirect 55 | golang.org/x/text v0.9.0 // indirect 56 | ) 57 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= 2 | github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= 3 | github.com/algorand/go-algorand-sdk/v2 v2.2.0 h1:zWwK+k/WArtZJUSkDXTDj4a0GUik2iOhFlPjLFDET6s= 4 | github.com/algorand/go-algorand-sdk/v2 v2.2.0/go.mod h1:+3+4EZmMUcQk6bgmtC5Ic5kKZE/g6SmfiW098tYLkPE= 5 | github.com/algorand/go-codec/codec v1.1.10 h1:zmWYU1cp64jQVTOG8Tw8wa+k0VfwgXIPbnDfiVa+5QA= 6 | github.com/algorand/go-codec/codec v1.1.10/go.mod h1:YkEx5nmr/zuCeaDYOIhlDg92Lxju8tj2d2NrYqP7g7k= 7 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 8 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 9 | github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= 10 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 11 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 12 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 13 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 14 | github.com/caarlos0/sshmarshal v0.1.0 h1:zTCZrDORFfWh526Tsb7vCm3+Yg/SfW/Ub8aQDeosk0I= 15 | github.com/caarlos0/sshmarshal v0.1.0/go.mod h1:7Pd/0mmq9x/JCzKauogNjSQEhivBclCQHfr9dlpDIyA= 16 | github.com/calyptia/go-bubble-table v0.2.1 h1:NWcVRyGCLuP7QIA29uUFSY+IjmWcmUWHjy5J/CPb0Rk= 17 | github.com/calyptia/go-bubble-table v0.2.1/go.mod h1:gJvzUOUzfQeA9JmgLumyJYWJMtuRQ7WxxTwc9tjEiGw= 18 | github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY= 19 | github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= 20 | github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY= 21 | github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= 22 | github.com/charmbracelet/glamour v0.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM273bISc= 23 | github.com/charmbracelet/glamour v0.6.0/go.mod h1:taqWV4swIMMbWALc0m7AfE9JkPSU8om2538k9ITBxOc= 24 | github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= 25 | github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= 26 | github.com/charmbracelet/keygen v0.4.2 h1:TNHua2MlXc6W1dQB2iW4msSZGKlb8RtxtmYDWUs4iRw= 27 | github.com/charmbracelet/keygen v0.4.2/go.mod h1:4e4FT3HSdLU/u83RfJWvzJIaVb8aX4MxtDlfXwpDJaI= 28 | github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= 29 | github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= 30 | github.com/charmbracelet/log v0.2.1 h1:1z7jpkk4yKyjwlmKmKMM5qnEDSpV32E7XtWhuv0mTZE= 31 | github.com/charmbracelet/log v0.2.1/go.mod h1:GwFfjewhcVDWLrpAbY5A0Hin9YOlEn40eWT4PNaxFT4= 32 | github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103 h1:wpHMERIN0pQZE635jWwT1dISgfjbpUcEma+fbPKSMCU= 33 | github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103/go.mod h1:0Vm2/8yBljiLDnGJHU8ehswfawrEybGk33j5ssqKQVM= 34 | github.com/charmbracelet/wish v1.1.1 h1:KdICASKd2oh2JPvk1Z4CJtAi97cFErXF7NKienPICO4= 35 | github.com/charmbracelet/wish v1.1.1/go.mod h1:xh4KZpSULw+Xqb9bcbhw92QAinVB75CVLWrFuyY6IVs= 36 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= 37 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= 38 | github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= 39 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 40 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 41 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 42 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 43 | github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= 44 | github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= 45 | github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 46 | github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 47 | github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= 48 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 49 | github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= 50 | github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= 51 | github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc h1:ZQrgZFsLzkw7o3CoDzsfBhx0bf/1rVBXrLy8dXKRe8o= 52 | github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc/go.mod h1:PyXUpnI3olx3bsPcHt98FGPX/KCFZ1Fi+hw1XLI6384= 53 | github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= 54 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 55 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 56 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 57 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 58 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 59 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 60 | github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8= 61 | github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= 62 | github.com/mattn/go-colorable v0.1.10/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 63 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 64 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 65 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 66 | github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= 67 | github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 68 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 69 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 70 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 71 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 72 | github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= 73 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 74 | github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg= 75 | github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM= 76 | github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 h1:kMlmsLSbjkikxQJ1IPwaM+7LJ9ltFu/fi8CRzvSnQmA= 77 | github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 78 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 79 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 80 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 81 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 82 | github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc= 83 | github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs= 84 | github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= 85 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= 86 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= 87 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 88 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 89 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 90 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 91 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 92 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 93 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 94 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 95 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 96 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 97 | github.com/urfave/cli/v3 v3.0.0-alpha4 h1:RJFGIs3mcalmc2YgliDh0Pa4l79S+Dqdz7cW8Fcp7Rg= 98 | github.com/urfave/cli/v3 v3.0.0-alpha4/go.mod h1:ZFqSEHhze0duJACOdz43I5IcnKhf4RoTlOoUMBUggOI= 99 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= 100 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= 101 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 102 | github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 103 | github.com/yuin/goldmark v1.5.2 h1:ALmeCk/px5FSm1MAcFBAsVKZjDuMVj8Tm7FFIlMJnqU= 104 | github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 105 | github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os= 106 | github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ= 107 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 108 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 109 | golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 110 | golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ= 111 | golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= 112 | golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= 113 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 114 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 115 | golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 116 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 117 | golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= 118 | golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= 119 | golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= 120 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 121 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 122 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 123 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 124 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 125 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 126 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 127 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 128 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 129 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 130 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 131 | golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 132 | golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 133 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 134 | golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 135 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 136 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 137 | golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= 138 | golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 139 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 140 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 141 | golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 142 | golang.org/x/term v0.7.0 h1:BEvjmm5fURWqcfbSKTdpkDXYBrUS1c0m8agp14W48vQ= 143 | golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= 144 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 145 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 146 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 147 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 148 | golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= 149 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 150 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 151 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 152 | golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= 153 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 154 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 155 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 156 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 157 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 158 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 159 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 160 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 161 | -------------------------------------------------------------------------------- /images/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algorand/node-ui/db173dff3783ecb484afe004d958b2e639c44908/images/demo.png -------------------------------------------------------------------------------- /images/wishlist_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algorand/node-ui/db173dff3783ecb484afe004d958b2e639c44908/images/wishlist_example.png -------------------------------------------------------------------------------- /messages/tui.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019-2022 Algorand, Inc. 2 | // This file is part of go-algorand 3 | // 4 | // go-algorand is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Affero General Public License as 6 | // published by the Free Software Foundation, either version 3 of the 7 | // License, or (at your option) any later version. 8 | // 9 | // go-algorand is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Affero General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Affero General Public License 15 | // along with go-algorand. If not, see . 16 | 17 | package messages 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "io" 23 | "net/http" 24 | "os" 25 | "path" 26 | "strings" 27 | 28 | "github.com/algorand/go-algorand-sdk/v2/client/v2/algod" 29 | "github.com/algorand/go-algorand-sdk/v2/client/v2/common/models" 30 | "github.com/algorand/go-algorand-sdk/v2/types" 31 | 32 | tea "github.com/charmbracelet/bubbletea" 33 | ) 34 | 35 | // Requestor provides an opaque pointer for an algod client. 36 | type Requestor struct { 37 | Client *algod.Client 38 | dataDir string 39 | } 40 | 41 | // MakeRequestor builds the requestor object. 42 | func MakeRequestor(client *algod.Client, dataDir string) *Requestor { 43 | return &Requestor{ 44 | Client: client, 45 | dataDir: dataDir, 46 | } 47 | } 48 | 49 | // NetworkMsg holds network information. 50 | type NetworkMsg struct { 51 | GenesisID string 52 | GenesisHash types.Digest 53 | NodeVersion string 54 | Err error 55 | } 56 | 57 | func formatVersion(ver models.Version) string { 58 | return fmt.Sprintf("%s %d.%d.%d (%s)", 59 | ver.Build.Channel, 60 | ver.Build.Major, 61 | ver.Build.Major, 62 | ver.Build.BuildNumber, 63 | ver.Build.CommitHash) 64 | } 65 | 66 | // GetNetworkCmd provides a tea.Cmd for fetching a NetworkMsg. 67 | func (r Requestor) GetNetworkCmd() tea.Cmd { 68 | return func() tea.Msg { 69 | ver, err := r.Client.Versions().Do(context.Background()) 70 | if err != nil { 71 | return NetworkMsg{ 72 | Err: err, 73 | } 74 | } 75 | 76 | var digest types.Digest 77 | if len(ver.GenesisHash) != len(digest) { 78 | return NetworkMsg{ 79 | Err: fmt.Errorf("unexpected genesis hash, wrong number of bytes"), 80 | } 81 | } 82 | copy(digest[:], ver.GenesisHash) 83 | 84 | return NetworkMsg{ 85 | GenesisID: ver.GenesisID, 86 | GenesisHash: digest, 87 | NodeVersion: formatVersion(ver), 88 | } 89 | } 90 | } 91 | 92 | // StatusMsg has node status information. 93 | type StatusMsg struct { 94 | Status models.NodeStatus 95 | Error error 96 | } 97 | 98 | // GetStatusCmd provides a tea.Cmd for fetching a StatusMsg. 99 | func (r Requestor) GetStatusCmd() tea.Cmd { 100 | return func() tea.Msg { 101 | resp, err := r.Client.Status().Do(context.Background()) 102 | //s, err := s.node.Status() 103 | return StatusMsg{ 104 | Status: resp, 105 | Error: err, 106 | } 107 | } 108 | } 109 | 110 | // AccountStatusMsg has account balance information. 111 | type AccountStatusMsg struct { 112 | Balances map[types.Address]map[uint64]uint64 113 | Err error 114 | } 115 | 116 | // GetAccountStatusCmd provides a tea.Cmd for fetching a AccountStatusMsg. 117 | func (r Requestor) GetAccountStatusCmd(accounts []types.Address) tea.Cmd { 118 | return func() tea.Msg { 119 | var rval AccountStatusMsg 120 | rval.Balances = make(map[types.Address]map[uint64]uint64) 121 | 122 | for _, acct := range accounts { 123 | resp, err := r.Client.AccountInformation(acct.String()).Do(context.Background()) 124 | if err != nil { 125 | return AccountStatusMsg{ 126 | Err: err, 127 | } 128 | } 129 | rval.Balances[acct] = make(map[uint64]uint64) 130 | 131 | // algos at the special index 132 | rval.Balances[acct][0] = resp.Amount 133 | 134 | // everything else 135 | for _, holding := range resp.Assets { 136 | rval.Balances[acct][holding.AssetId] = holding.Amount 137 | } 138 | } 139 | 140 | return rval 141 | } 142 | } 143 | 144 | func doFastCatchupRequest(verb, network string) error { 145 | resp, err := http.Get(fmt.Sprintf("https://algorand-catchpoints.s3.us-east-2.amazonaws.com/channel/%s/latest.catchpoint", network)) 146 | if err != nil { 147 | panic(err) 148 | } 149 | body, err := io.ReadAll(resp.Body) 150 | if err != nil { 151 | panic(err) 152 | } 153 | catchpoint := strings.Replace(string(body), "#", "%23", 1) 154 | 155 | //start fast catchup 156 | url := fmt.Sprintf("http://localhost:8080/v2/catchup/%s", catchpoint) 157 | url = url[:len(url)-1] // remove \n 158 | apiToken, err := os.ReadFile(path.Join(os.Getenv("ALGORAND_DATA"), "algod.admin.token")) 159 | if err != nil { 160 | panic(err) 161 | } 162 | req, err := http.NewRequest(verb, url, nil) 163 | if err != nil { 164 | panic(err) 165 | } 166 | req.Header.Set("X-Algo-Api-Token", string(apiToken)) 167 | req.Header.Set("Content-Type", "application/json") 168 | 169 | client := &http.Client{} 170 | resp, err = client.Do(req) 171 | if err != nil { 172 | return err 173 | } 174 | defer resp.Body.Close() 175 | 176 | return nil 177 | } 178 | 179 | // StartFastCatchup attempts to start fast catchup for a given network. 180 | func StartFastCatchup(network string) tea.Cmd { 181 | return func() tea.Msg { 182 | err := doFastCatchupRequest(http.MethodPost, network) 183 | if err != nil { 184 | panic(err) 185 | } 186 | return nil 187 | } 188 | } 189 | 190 | // StopFastCatchup attempts to stop fast catchup for a given network. 191 | func StopFastCatchup(network string) tea.Cmd { 192 | return func() tea.Msg { 193 | err := doFastCatchupRequest(http.MethodDelete, network) 194 | if err != nil { 195 | panic(err) 196 | } 197 | return nil 198 | } 199 | } 200 | 201 | // GetConfigs returns the node config.json file if possible. 202 | func GetConfigs() string { 203 | // TODO: Optional 204 | configs, err := os.ReadFile(path.Join(os.Getenv("ALGORAND_DATA"), "config.json")) 205 | if err != nil { 206 | return "config.json file not found" 207 | } 208 | return string(configs) 209 | } 210 | -------------------------------------------------------------------------------- /tui/internal/bubbles/about/about.go: -------------------------------------------------------------------------------- 1 | package about 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/viewport" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/charmbracelet/glamour" 7 | "github.com/muesli/reflow/indent" 8 | ) 9 | 10 | // Model represents the about bubble. 11 | type Model struct { 12 | heightMargin int 13 | viewport viewport.Model 14 | } 15 | 16 | // New creates the about Model. 17 | func New(heightMargin int, content string) Model { 18 | m := Model{ 19 | heightMargin: heightMargin, 20 | viewport: viewport.New(0, 0), 21 | } 22 | m.setSize(80, 20) 23 | 24 | r, _ := glamour.NewTermRenderer( 25 | glamour.WithStandardStyle("dark"), 26 | glamour.WithWordWrap(80), 27 | glamour.WithEmoji(), 28 | ) 29 | c, _ := r.Render(content) 30 | m.viewport.SetContent(indent.String(c, 7)) 31 | return m 32 | } 33 | 34 | // Init is part of the tea.Model interface. 35 | func (m Model) Init() tea.Cmd { 36 | return nil 37 | } 38 | 39 | // Update is part of the tea.Model interface. 40 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 41 | var ( 42 | cmd tea.Cmd 43 | cmds []tea.Cmd 44 | ) 45 | 46 | switch msg := msg.(type) { 47 | case tea.WindowSizeMsg: 48 | m.setSize(msg.Width, msg.Height) 49 | } 50 | m.viewport, cmd = m.viewport.Update(msg) 51 | cmds = append(cmds, cmd) 52 | 53 | return m, tea.Batch(cmds...) 54 | } 55 | 56 | // View is part of the tea.Model interface. 57 | func (m Model) View() string { 58 | return m.viewport.View() 59 | } 60 | 61 | func (m *Model) setSize(width, height int) { 62 | m.viewport.Width = width 63 | m.viewport.Height = height - m.heightMargin 64 | } 65 | -------------------------------------------------------------------------------- /tui/internal/bubbles/about/help.go: -------------------------------------------------------------------------------- 1 | package about 2 | 3 | // GetHelpContent returns the help page markdown string. 4 | func GetHelpContent() string { 5 | return ` 6 | # Algorand Node UI :smiley_cat: 7 | 8 | An **awesome** node Terminal User Interface for node runners. 9 | 10 | Easy access to **important** tools and node information. 11 | 12 | # Status 13 | 14 | Continuous status is available for: 15 | * Network information. 16 | * Protocol upgrade status. 17 | * Catchup sync time. 18 | * Fast catchup progress. 19 | 20 | # Explorer 21 | 22 | ## Blocks 23 | 24 | Full real-time access to block information, and aggregations including: 25 | * Number of transactions. 26 | * Transaction types. 27 | * Sum of payment transactions. 28 | * You get a gold star for actually reading this. 29 | * Unique assets used in asset transactions. 30 | * Unique applications used in applications. 31 | 32 | ## Transactions 33 | 34 | Drill into a block for a detailed transaction breakdown: 35 | * sender 36 | * type 37 | * transfer amount for payment / asset transfer transactions 38 | * signature type, including inner-transactions 39 | 40 | ## Raw Transaction 41 | 42 | View the raw transaction details. 43 | 44 | # Utilities 45 | 46 | Shortcuts for handy utilities. 47 | 48 | # Accounts 49 | 50 | View all of your accounts along with recent transactions. 51 | 52 | # Configuration 53 | 54 | Full node configuration details. 55 | ` + "```json" + ` 56 | { 57 | "Version": 16, 58 | "AccountsRebuildSynchronousMode": 1, 59 | "AnnounceParticipationKey": true, 60 | "Archival": false, 61 | "BaseLoggerDebugLevel": 4, 62 | "BroadcastConnectionsLimit": -1, 63 | "CadaverSizeTarget": 1073741824, 64 | ... 65 | } 66 | ` + "```" + ` 67 | 68 | # Help 69 | 70 | Let's be realistic for a moment, this software was so 71 | intuitive and fun to use that you have no need for help. 72 | 73 | But don't worry, it's right here if you need it! 74 | ` 75 | } 76 | -------------------------------------------------------------------------------- /tui/internal/bubbles/about/utils.go: -------------------------------------------------------------------------------- 1 | package about 2 | 3 | // GetUtilsContent returns the utilities' markdown string. 4 | func GetUtilsContent() string { 5 | return `## From this tab use the following keys to launch utility functions. 6 | 7 | * **F** Immediately begin a fast catchup, status is displayed. 8 | 9 | * **A** Abort an ongoing fast catchup. 10 | 11 | * **S** Send a payment transaction. 12 | 13 | * **D** Delete block from the blockchain. 14 | 15 | * **C** Chargeback transaction. 16 | 17 | * **H** Hack relay. 18 | 19 | * **P** Poison DNS cache. 20 | ` 21 | } 22 | -------------------------------------------------------------------------------- /tui/internal/bubbles/accounts/accounts.go: -------------------------------------------------------------------------------- 1 | package accounts 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | "time" 8 | 9 | "github.com/charmbracelet/bubbles/viewport" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | 13 | "github.com/algorand/go-algorand-sdk/v2/types" 14 | 15 | "github.com/algorand/node-ui/messages" 16 | "github.com/algorand/node-ui/tui/internal/style" 17 | ) 18 | 19 | var ( 20 | titleStyle = func() lipgloss.Style { 21 | b := lipgloss.RoundedBorder() 22 | b.Right = "├" 23 | return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) 24 | }() 25 | 26 | infoStyle = func() lipgloss.Style { 27 | b := lipgloss.RoundedBorder() 28 | b.Left = "┤" 29 | return titleStyle.Copy().BorderStyle(b) 30 | }() 31 | ) 32 | 33 | type balance struct { 34 | MicroAlgos uint64 35 | TimeStamp time.Time 36 | } 37 | 38 | type account struct { 39 | Balances map[uint64]uint64 40 | BalanceHistory []balance 41 | } 42 | 43 | func makeAccount() *account { 44 | return &account{ 45 | Balances: make(map[uint64]uint64), 46 | BalanceHistory: []balance{ 47 | {0, time.Now()}, 48 | {0, time.Now()}, 49 | {0, time.Now()}, 50 | }} 51 | } 52 | 53 | // Model representing the account bubble. 54 | type Model struct { 55 | accounts []types.Address 56 | Accounts map[types.Address]*account 57 | 58 | Err error 59 | style *style.Styles 60 | viewport viewport.Model 61 | heightMargin int 62 | 63 | requestor *messages.Requestor 64 | } 65 | 66 | // New creates the accounts Model. 67 | func New(style *style.Styles, requestor *messages.Requestor, initialHeight int, heightMargin int, accounts []types.Address) Model { 68 | rval := Model{ 69 | Accounts: make(map[types.Address]*account), 70 | style: style, 71 | viewport: viewport.New(0, 0), 72 | heightMargin: heightMargin, 73 | requestor: requestor, 74 | } 75 | rval.setSize(80, initialHeight) 76 | rval.SetAccounts(accounts) 77 | return rval 78 | } 79 | 80 | // SetAccounts updates the accounts to monitor. 81 | func (m *Model) SetAccounts(accounts []types.Address) { 82 | updated := make(map[types.Address]*account) 83 | for _, addr := range accounts { 84 | if acct, ok := m.Accounts[addr]; ok { 85 | updated[addr] = acct 86 | } else { 87 | updated[addr] = makeAccount() 88 | } 89 | } 90 | m.Accounts = updated 91 | m.accounts = accounts 92 | } 93 | 94 | func (m *Model) setSize(width, height int) { 95 | footerHeight := lipgloss.Height(m.footerView()) 96 | m.viewport.Width = width 97 | m.viewport.Height = height - m.heightMargin - footerHeight 98 | } 99 | 100 | // Init is part of the tea.Model interface. 101 | func (m Model) Init() tea.Cmd { 102 | return m.requestor.GetAccountStatusCmd(m.accounts) 103 | } 104 | 105 | // Update is part of the tea.Model interface. 106 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 107 | var ( 108 | cmd tea.Cmd 109 | cmds []tea.Cmd 110 | ) 111 | 112 | switch msg := msg.(type) { 113 | case tea.WindowSizeMsg: 114 | m.setSize(msg.Width, msg.Height) 115 | 116 | case messages.AccountStatusMsg: 117 | cmds = append(cmds, 118 | tea.Tick(5*time.Second, func(time.Time) tea.Msg { 119 | return m.requestor.GetAccountStatusCmd(m.accounts)() 120 | }), 121 | ) 122 | 123 | for msgAddress, msgBalances := range msg.Balances { 124 | acct := m.Accounts[msgAddress] 125 | 126 | // Don't update if the balance didn't change 127 | if msgBalances[0] == acct.Balances[0] { 128 | break 129 | } 130 | 131 | newBalance := balance{ 132 | MicroAlgos: msgBalances[0], 133 | TimeStamp: time.Now(), 134 | } 135 | 136 | // Prepend the balance 137 | tmpList := append([]balance{newBalance}, acct.BalanceHistory...) 138 | if len(tmpList) > 3 { 139 | tmpList = tmpList[:3] 140 | } 141 | acct.BalanceHistory = tmpList 142 | acct.Balances = msgBalances 143 | 144 | m.Accounts[msgAddress] = acct 145 | } 146 | 147 | m.viewport.SetContent(m.buildString()) 148 | } 149 | 150 | m.viewport, cmd = m.viewport.Update(msg) 151 | cmds = append(cmds, cmd) 152 | 153 | return m, tea.Batch(cmds...) 154 | } 155 | 156 | // View is part of the tea.Model interface. 157 | func (m Model) View() string { 158 | 159 | builder := strings.Builder{} 160 | builder.WriteString(fmt.Sprintf("%s\n%s", m.viewport.View(), m.footerView())) 161 | return builder.String() 162 | } 163 | 164 | func (m Model) buildString() string { 165 | builder := strings.Builder{} 166 | 167 | keys := make([]string, 0, len(m.Accounts)) 168 | for k := range m.Accounts { 169 | keys = append(keys, k.String()) 170 | } 171 | sort.Strings(keys) 172 | 173 | for _, account := range keys { 174 | accountType, _ := types.DecodeAddress(account) 175 | v := m.Accounts[accountType] 176 | builder.WriteString(fmt.Sprintf("%s %s\n", m.style.AccountBoldText.Render("Account:"), m.style.AccountYellowText.Render(account))) 177 | 178 | algoStr := fmt.Sprintf(" %f Algos", float64(v.Balances[0])/1000000.0) 179 | builder.WriteString(m.style.AccountBlueText.Render(algoStr) + "\n") 180 | for _, a := range v.BalanceHistory { 181 | if a.MicroAlgos == 0 { 182 | builder.WriteString("\n") 183 | } else { 184 | pastStr := fmt.Sprintf(" %f Algos @ %s\n", float64(a.MicroAlgos)/1000000, a.TimeStamp.Format("2006-01-02 15:04:05.1234")) 185 | builder.WriteString(pastStr) 186 | } 187 | } 188 | 189 | } 190 | 191 | return m.style.Account.Render(builder.String()) 192 | } 193 | func (m Model) footerView() string { 194 | info := infoStyle.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100)) 195 | line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(info))) 196 | return lipgloss.JoinHorizontal(lipgloss.Center, line, info) 197 | } 198 | 199 | func max(a, b int) int { 200 | if a > b { 201 | return a 202 | } 203 | return b 204 | } 205 | -------------------------------------------------------------------------------- /tui/internal/bubbles/configs/configs.go: -------------------------------------------------------------------------------- 1 | package configs 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/charmbracelet/bubbles/viewport" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | 11 | "github.com/algorand/node-ui/messages" 12 | ) 13 | 14 | var ( 15 | titleStyle = func() lipgloss.Style { 16 | b := lipgloss.RoundedBorder() 17 | b.Right = "├" 18 | return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) 19 | }() 20 | 21 | infoStyle = func() lipgloss.Style { 22 | b := lipgloss.RoundedBorder() 23 | b.Left = "┤" 24 | return titleStyle.Copy().BorderStyle(b) 25 | }() 26 | ) 27 | 28 | // Model representing the configs page. 29 | type Model struct { 30 | heightMargin int 31 | viewport viewport.Model 32 | } 33 | 34 | // New creates a Model. 35 | func New(heightMargin int) Model { 36 | m := Model{ 37 | viewport: viewport.New(0, 0), 38 | heightMargin: heightMargin, 39 | } 40 | m.setSize(80, 20) 41 | return m 42 | } 43 | 44 | // ConfigContent allows the update function to find its config content. 45 | type ConfigContent string 46 | 47 | func (m Model) getContent() tea.Cmd { 48 | return func() tea.Msg { 49 | return ConfigContent(messages.GetConfigs()) 50 | } 51 | } 52 | 53 | // Init is part of the tea.Model interface. 54 | func (m Model) Init() tea.Cmd { 55 | return m.getContent() 56 | } 57 | 58 | func (m *Model) setSize(width, height int) { 59 | headerHeight := lipgloss.Height(m.headerView()) 60 | footerHeight := lipgloss.Height(m.footerView()) 61 | 62 | m.viewport.Width = width 63 | m.viewport.Height = height - m.heightMargin - headerHeight - footerHeight 64 | } 65 | 66 | // Update is part of the tea.Model interface. 67 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 68 | var ( 69 | cmd tea.Cmd 70 | cmds []tea.Cmd 71 | ) 72 | 73 | switch msg := msg.(type) { 74 | case ConfigContent: 75 | // For some reason tabs make the viewport go crazy 76 | //m.viewport.SetContent(string(msg)) 77 | m.viewport.SetContent(strings.ReplaceAll(string(msg), "\t", " ")) 78 | 79 | case tea.WindowSizeMsg: 80 | m.setSize(msg.Width, msg.Height) 81 | } 82 | 83 | // Handle keyboard and mouse events in the viewport 84 | m.viewport, cmd = m.viewport.Update(msg) 85 | cmds = append(cmds, cmd) 86 | 87 | return m, tea.Batch(cmds...) 88 | } 89 | 90 | // View is part of the tea.Model interface. 91 | func (m Model) View() string { 92 | 93 | builder := strings.Builder{} 94 | builder.WriteString(fmt.Sprintf("%s\n%s\n%s", m.headerView(), m.viewport.View(), m.footerView())) 95 | return builder.String() 96 | } 97 | 98 | func (m Model) headerView() string { 99 | title := titleStyle.Render("Node configurations") 100 | line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(title))) 101 | return lipgloss.JoinHorizontal(lipgloss.Center, title, line) 102 | } 103 | 104 | func (m Model) footerView() string { 105 | info := infoStyle.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100)) 106 | line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(info))) 107 | return lipgloss.JoinHorizontal(lipgloss.Center, line, info) 108 | } 109 | 110 | func max(a, b int) int { 111 | if a > b { 112 | return a 113 | } 114 | return b 115 | } 116 | -------------------------------------------------------------------------------- /tui/internal/bubbles/explorer/blocks.go: -------------------------------------------------------------------------------- 1 | package explorer 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strconv" 7 | 8 | "github.com/algorand/go-algorand-sdk/v2/client/v2/common/models" 9 | "github.com/algorand/go-algorand-sdk/v2/types" 10 | table "github.com/calyptia/go-bubble-table" 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/charmbracelet/lipgloss" 13 | ) 14 | 15 | // BlockItem is used by the list bubble. 16 | type BlockItem struct { 17 | Round uint64 18 | Block models.BlockResponse 19 | } 20 | 21 | // Hacked these in to workaround missing style options in table model 22 | var inactiveStyle = lipgloss.NewStyle() 23 | var activeStyle = inactiveStyle.Copy().Foreground(lipgloss.Color("#B083EA")).Bold(true) 24 | var keyStyle = inactiveStyle.Copy().Width(10).Foreground(lipgloss.Color("#A3A322")).Bold(true) 25 | 26 | var blockTableHeader = []string{" ROUND", "Txns", "Pay", "[Sum λ]", "Axfer", "Acfg", "Afrz", "[Unique]", "Appl", "[Unique]", "Proposer"} 27 | 28 | func proposer(cert *map[string]interface{}) string { 29 | if cert == nil { 30 | return "" 31 | } 32 | 33 | // There must be a better way than this... 34 | prop, ok := (*cert)["prop"] 35 | if ok { 36 | switch p := prop.(type) { 37 | case map[interface{}]interface{}: 38 | for k, v := range p { 39 | switch c := k.(type) { 40 | case string: 41 | if c == "oprop" { 42 | switch c2 := v.(type) { 43 | case []byte: 44 | var addr types.Address 45 | copy(addr[:], c2) 46 | return addr.String() 47 | } 48 | } 49 | } 50 | } 51 | } 52 | } 53 | 54 | return "" 55 | } 56 | 57 | func computeBlockRow(b BlockItem) string { 58 | block := b.Block.Block 59 | 60 | typeCount := make(map[types.TxType]uint) 61 | var paymentsTotal uint64 62 | assets := make(map[uint64]struct{}) 63 | apps := make(map[uint64]struct{}) 64 | for _, tx := range block.Payset { 65 | typeCount[tx.Txn.Type]++ 66 | 67 | switch tx.Txn.Type { 68 | 69 | case types.PaymentTx: 70 | paymentsTotal += uint64(tx.Txn.PaymentTxnFields.Amount) 71 | case types.ApplicationCallTx: 72 | id := uint64(tx.Txn.ApplicationCallTxnFields.ApplicationID) 73 | if id == 0 { 74 | id = tx.ApplyData.ApplicationID 75 | } 76 | if id == 0 { 77 | break 78 | } 79 | if _, ok := apps[id]; !ok { 80 | apps[id] = struct{}{} 81 | } 82 | case types.AssetTransferTx: 83 | fallthrough 84 | case types.AssetFreezeTx: 85 | fallthrough 86 | case types.AssetConfigTx: 87 | id := uint64(tx.Txn.AssetTransferTxnFields.XferAsset) 88 | if id == 0 { 89 | id = tx.ApplyData.ConfigAsset 90 | } 91 | if id == 0 { 92 | id = uint64(tx.Txn.AssetConfigTxnFields.ConfigAsset) 93 | } 94 | if id == 0 { 95 | id = uint64(tx.Txn.AssetFreezeTxnFields.FreezeAsset) 96 | } 97 | if id == 0 { 98 | break 99 | } 100 | if _, ok := assets[id]; !ok { 101 | assets[id] = struct{}{} 102 | } 103 | } 104 | } 105 | 106 | return fmt.Sprintf("\t%d\t%d\t%f\t%d\t%d\t%d\t%d\t%d\t%d\t%s", 107 | len(b.Block.Block.Payset), 108 | typeCount[types.PaymentTx], 109 | float64(paymentsTotal)/float64(10000), 110 | typeCount[types.AssetTransferTx], 111 | typeCount[types.AssetConfigTx], 112 | typeCount[types.AssetFreezeTx], 113 | len(assets), 114 | typeCount[types.ApplicationCallTx], 115 | len(apps), 116 | proposer(b.Block.Cert)) 117 | } 118 | 119 | // Render implements the Row interface to display a row of data. 120 | func (i BlockItem) Render(w io.Writer, model table.Model, index int) { 121 | var cursor string 122 | if index == model.Cursor() { 123 | cursor = "> " 124 | } else { 125 | cursor = " " 126 | } 127 | 128 | cursor = activeStyle.Render(cursor) 129 | //round := keyStyle.Render(strconv.FormatUint(uint64(i.Block.Round()), 10)) 130 | round := keyStyle.Render(strconv.FormatUint(i.Round, 10)) 131 | rest := computeBlockRow(i) 132 | if index == model.Cursor() { 133 | rest = activeStyle.Render(rest) 134 | } else { 135 | rest = inactiveStyle.Render(rest) 136 | } 137 | fmt.Fprintf(w, "%s%s%s\n", cursor, round, rest) 138 | } 139 | 140 | func (m *Model) updateBlockTable() { 141 | if len(m.blocks) <= 0 { 142 | return 143 | } 144 | 145 | var rows []table.Row 146 | for _, b := range m.blocks { 147 | rows = append(rows, b) 148 | } 149 | 150 | m.table.SetRows(rows) 151 | } 152 | 153 | func (m *Model) initBlocks() { 154 | t := table.New(blockTableHeader, 0, 0) 155 | t.KeyMap.Up.SetKeys(append(t.KeyMap.Up.Keys(), "k")...) 156 | t.KeyMap.Down.SetKeys(append(t.KeyMap.Down.Keys(), "j")...) 157 | t.Styles.Title = m.style.StatusBoldText 158 | m.table = t 159 | m.setSize(m.width, m.height) 160 | m.updateBlockTable() 161 | } 162 | 163 | // updateBlocks mimics the tea.Model update function. 164 | func (m Model) updateBlocks(msg tea.Msg) (Model, tea.Cmd) { 165 | switch msg.(type) { 166 | case BlocksMsg: 167 | if m.state == blockState { 168 | m.updateBlockTable() 169 | } 170 | } 171 | 172 | return m, nil 173 | } 174 | -------------------------------------------------------------------------------- /tui/internal/bubbles/explorer/explorer.go: -------------------------------------------------------------------------------- 1 | package explorer 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | table "github.com/calyptia/go-bubble-table" 7 | "github.com/charmbracelet/bubbles/key" 8 | "github.com/charmbracelet/bubbles/viewport" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/lipgloss" 11 | 12 | "github.com/algorand/go-algorand-sdk/v2/encoding/msgpack" 13 | 14 | "github.com/algorand/node-ui/messages" 15 | "github.com/algorand/node-ui/tui/internal/constants" 16 | "github.com/algorand/node-ui/tui/internal/style" 17 | ) 18 | 19 | type state int 20 | 21 | const ( 22 | blockState = iota 23 | paysetState 24 | txnState 25 | ) 26 | 27 | const initialBlocks = 25 28 | 29 | type blocks []BlockItem 30 | type txnItems []transactionItem 31 | 32 | // Model for the block explorer bubble. 33 | type Model struct { 34 | state state 35 | 36 | width int 37 | widthMargin int 38 | height int 39 | heightMargin int 40 | style *style.Styles 41 | 42 | // for blocks page 43 | blocks blocks 44 | 45 | // cache for transactions page 46 | transactions txnItems 47 | 48 | table table.Model 49 | txnView viewport.Model 50 | requestor *messages.Requestor 51 | } 52 | 53 | // New constructs the explorer Model. 54 | func New(styles *style.Styles, requestor *messages.Requestor, width, widthMargin, height, heightMargin int) Model { 55 | m := Model{ 56 | state: blockState, 57 | style: styles, 58 | width: width, 59 | widthMargin: widthMargin, 60 | height: height, 61 | heightMargin: heightMargin, 62 | requestor: requestor, 63 | } 64 | m.initBlocks() 65 | return m 66 | } 67 | 68 | // BlocksMsg contains new block information. 69 | type BlocksMsg struct { 70 | Blocks []BlockItem 71 | Err error 72 | } 73 | 74 | // initBlocksCmd is the initializer command. 75 | func (m Model) initBlocksCmd() tea.Msg { 76 | status, err := m.requestor.Client.Status().Do(context.Background()) 77 | if err != nil { 78 | return BlocksMsg{ 79 | Err: err, 80 | } 81 | } 82 | return m.getBlocks(status.LastRound-initialBlocks, status.LastRound)() 83 | } 84 | 85 | func (m *Model) getBlocks(first, last uint64) tea.Cmd { 86 | return func() tea.Msg { 87 | var result BlocksMsg 88 | for i := last; i >= first; i-- { 89 | block, err := m.requestor.Client.BlockRaw(i).Do(context.Background()) 90 | if err != nil { 91 | result.Err = err 92 | return result 93 | } 94 | item := BlockItem{Round: i} 95 | err = lenientDecode(block, &item.Block) 96 | if err != nil { 97 | result.Err = err 98 | return result 99 | } 100 | result.Blocks = append(result.Blocks, item) 101 | } 102 | return result 103 | } 104 | } 105 | 106 | // Init is part of the tea.Model interface. 107 | func (m Model) Init() tea.Cmd { 108 | return m.initBlocksCmd 109 | } 110 | 111 | func lenientDecode(data []byte, objptr interface{}) error { 112 | return msgpack.NewLenientDecoder(bytes.NewReader(data)).Decode(&objptr) 113 | } 114 | 115 | func (m Model) nextBlockCmd(round uint64) tea.Cmd { 116 | return func() tea.Msg { 117 | _, err := m.requestor.Client.StatusAfterBlock(round).Do(context.Background()) 118 | if err != nil { 119 | return BlocksMsg{Err: err} 120 | } 121 | blk, err := m.requestor.Client.BlockRaw(round).Do(context.Background()) 122 | if err != nil { 123 | return BlocksMsg{Err: err} 124 | } 125 | item := BlockItem{Round: round} 126 | err = lenientDecode(blk, &item.Block) 127 | if err != nil { 128 | return err 129 | } 130 | 131 | if err != nil { 132 | return BlocksMsg{ 133 | Err: err, 134 | } 135 | } 136 | return BlocksMsg{ 137 | Blocks: []BlockItem{item}, 138 | } 139 | } 140 | } 141 | 142 | func (m *Model) setSize(width, height int) { 143 | m.width = width 144 | m.height = height 145 | verticalFrameSize := m.style.Bottom.GetVerticalFrameSize() 146 | m.table.SetSize(width-m.widthMargin, height-m.heightMargin-verticalFrameSize) 147 | m.txnView.Width = width - m.widthMargin 148 | m.txnView.Height = height - m.heightMargin - lipgloss.Height(m.headerView()) - lipgloss.Height(m.footerView()) 149 | } 150 | 151 | // aUpdate is part of the tea.Model interface. 152 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 153 | var updateCmd tea.Cmd 154 | var cmds []tea.Cmd 155 | switch msg := msg.(type) { 156 | case tea.KeyMsg: 157 | // navigate into explorer views 158 | switch { 159 | case key.Matches(msg, constants.Keys.Forward): 160 | switch m.state { 161 | case blockState: 162 | // Select transactions. 163 | m.state = paysetState 164 | switch block := m.table.SelectedRow().(type) { 165 | case BlockItem: 166 | m.transactions = make([]transactionItem, 0) 167 | for _, txn := range block.Block.Block.Payset { 168 | t := txn 169 | m.transactions = append(m.transactions, transactionItem{&t}) 170 | } 171 | } 172 | m.initTransactions() 173 | case paysetState: 174 | m.state = txnState 175 | switch txn := m.table.SelectedRow().(type) { 176 | case transactionItem: 177 | m.initTransaction(txn.SignedTxnInBlock) 178 | } 179 | } 180 | 181 | // navigate out of explorer views 182 | case key.Matches(msg, constants.Keys.Back): 183 | switch m.state { 184 | case paysetState: 185 | m.state = blockState 186 | m.initBlocks() 187 | case txnState: 188 | m.state = paysetState 189 | } 190 | } 191 | 192 | case tea.WindowSizeMsg: 193 | m.setSize(msg.Width, msg.Height) 194 | 195 | case BlocksMsg: 196 | // append Blocks 197 | backup := m.blocks 198 | m.blocks = msg.Blocks 199 | m.blocks = append(m.blocks, backup...) 200 | next := uint64(0) 201 | if len(m.blocks) > 0 { 202 | next = m.blocks[0].Round + 1 203 | } 204 | cmds = append(cmds, m.nextBlockCmd(next)) 205 | } 206 | 207 | t, tableCmd := m.table.Update(msg) 208 | m.table = t 209 | cmds = append(cmds, tableCmd) 210 | 211 | switch m.state { 212 | case blockState: 213 | m, updateCmd = m.updateBlocks(msg) 214 | return m, tea.Batch(append(cmds, updateCmd)...) 215 | case paysetState: 216 | return m, nil 217 | case txnState: 218 | m.txnView, updateCmd = m.txnView.Update(msg) 219 | return m, tea.Batch(append(cmds, updateCmd)...) 220 | } 221 | 222 | return m, nil 223 | } 224 | 225 | // View is part of the tea.Model interface. 226 | func (m Model) View() string { 227 | switch m.state { 228 | case blockState, paysetState: 229 | return m.style.Bottom.Render(m.table.View()) 230 | case txnState: 231 | return m.viewTransaction() 232 | } 233 | return "" 234 | } 235 | -------------------------------------------------------------------------------- /tui/internal/bubbles/explorer/transaction.go: -------------------------------------------------------------------------------- 1 | package explorer 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/charmbracelet/lipgloss" 8 | "github.com/muesli/reflow/indent" 9 | 10 | "github.com/algorand/go-algorand-sdk/v2/encoding/json" 11 | "github.com/algorand/go-algorand-sdk/v2/types" 12 | ) 13 | 14 | var ( 15 | titleStyle = func() lipgloss.Style { 16 | b := lipgloss.RoundedBorder() 17 | b.Right = "├" 18 | return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) 19 | }() 20 | 21 | middleStyle = func() lipgloss.Style { 22 | b := lipgloss.RoundedBorder() 23 | b.Left = "┤" 24 | b.Right = "├" 25 | return titleStyle.Copy().BorderStyle(b) 26 | }() 27 | ) 28 | 29 | func (m *Model) initTransaction(txn *types.SignedTxnInBlock) { 30 | m.txnView.YOffset = 0 31 | m.txnView.SetContent(indent.String(string(json.Encode(txn)), 6)) 32 | } 33 | 34 | func max(a, b int) int { 35 | if a > b { 36 | return a 37 | } 38 | return b 39 | } 40 | 41 | func (m Model) headerView() string { 42 | info := middleStyle.Render(fmt.Sprintf("%3.f%%", m.txnView.ScrollPercent()*100)) 43 | //title := titleStyle.Render(fmt.Sprintf("Txn: %s", m.txn.Txn.ID())) 44 | title := titleStyle.Render(fmt.Sprintf("Txn: %s", "TODO: Compute ID")) 45 | line := strings.Repeat("─", max(0, m.txnView.Width-lipgloss.Width(title)-lipgloss.Width(info)-1)) 46 | return lipgloss.JoinHorizontal(lipgloss.Center, title, "─", info, line) 47 | } 48 | 49 | func (m Model) footerView() string { 50 | line := strings.Repeat("─", max(0, m.txnView.Width)) 51 | return lipgloss.JoinHorizontal(lipgloss.Center, line) 52 | } 53 | 54 | func (m Model) viewTransaction() string { 55 | return lipgloss.JoinVertical(0, 56 | m.headerView(), 57 | m.txnView.View(), 58 | m.footerView(), 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /tui/internal/bubbles/explorer/transactions.go: -------------------------------------------------------------------------------- 1 | package explorer 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strconv" 7 | 8 | table "github.com/calyptia/go-bubble-table" 9 | 10 | "github.com/algorand/go-algorand-sdk/v2/types" 11 | ) 12 | 13 | // transactionItem is used by the list bubble. 14 | type transactionItem struct { 15 | *types.SignedTxnInBlock 16 | } 17 | 18 | func formatAmount(txn *types.SignedTxnInBlock) string { 19 | switch txn.Txn.Type { 20 | case types.PaymentTx: 21 | return fmt.Sprintf("%f", txn.Txn.Amount.ToAlgos()) 22 | case types.AssetTransferTx: 23 | return strconv.FormatUint(txn.Txn.AssetTransferTxnFields.AssetAmount, 10) 24 | } 25 | return "-" 26 | } 27 | 28 | var transactionTableHeader = []string{" INTRA", "type", "amount", "sigtype", "fee", "has-note", "sender"} 29 | 30 | func computeTxnRow(b transactionItem) string { 31 | var sigtype string 32 | if !(b.Sig == types.Signature{}) { 33 | sigtype = "ed25519" 34 | } else if !b.Msig.Blank() { 35 | sigtype = "msig" 36 | } else if !b.Lsig.Blank() { 37 | sigtype = "lsig" 38 | } else { 39 | sigtype = "inner-txn" 40 | } 41 | 42 | return fmt.Sprintf("\t%s\t%s\t%s\t%f\t%t\t%s", 43 | b.Txn.Type, 44 | formatAmount(b.SignedTxnInBlock), 45 | sigtype, 46 | b.Txn.Fee.ToAlgos(), 47 | len(b.Txn.Note) > 0, 48 | b.Txn.Sender.String(), 49 | ) 50 | } 51 | 52 | func (i transactionItem) Render(w io.Writer, model table.Model, index int) { 53 | var cursor string 54 | if index == model.Cursor() { 55 | cursor = "> " 56 | } else { 57 | cursor = " " 58 | } 59 | 60 | cursor = activeStyle.Render(cursor) 61 | intra := keyStyle.Render(strconv.FormatUint(uint64(index), 10)) 62 | rest := computeTxnRow(i) 63 | if index == model.Cursor() { 64 | rest = activeStyle.Render(rest) 65 | } else { 66 | rest = inactiveStyle.Render(rest) 67 | } 68 | fmt.Fprintf(w, "%s%s%s\n", cursor, intra, rest) 69 | } 70 | 71 | func (m *Model) updateTxnTable() { 72 | var rows []table.Row 73 | for _, t := range m.transactions { 74 | rows = append(rows, t) 75 | } 76 | 77 | m.table.SetRows(rows) 78 | } 79 | 80 | func (m *Model) initTransactions() { 81 | t := table.New(transactionTableHeader, 0, 0) 82 | t.KeyMap.Up.SetKeys(append(t.KeyMap.Up.Keys(), "k")...) 83 | t.KeyMap.Down.SetKeys(append(t.KeyMap.Down.Keys(), "j")...) 84 | t.Styles.Title = m.style.StatusBoldText 85 | m.table = t 86 | m.setSize(m.width, m.height) 87 | m.updateTxnTable() 88 | } 89 | -------------------------------------------------------------------------------- /tui/internal/bubbles/footer/footer.go: -------------------------------------------------------------------------------- 1 | package footer 2 | 3 | import ( 4 | "github.com/algorand/node-ui/messages" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/charmbracelet/lipgloss" 8 | 9 | "github.com/algorand/node-ui/tui/internal/style" 10 | ) 11 | 12 | // Model for the footer. 13 | type Model struct { 14 | width int 15 | height int 16 | style *style.Styles 17 | 18 | network messages.NetworkMsg 19 | } 20 | 21 | // New creates the footer Model. 22 | func New(s *style.Styles) Model { 23 | return Model{style: s} 24 | } 25 | 26 | // Init is part of the tea.Model interface. 27 | func (m Model) Init() tea.Cmd { 28 | return nil 29 | } 30 | 31 | // Update is part of the tea.Model interface. 32 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 33 | switch msg := msg.(type) { 34 | case tea.WindowSizeMsg: 35 | m.width = msg.Width 36 | m.height = msg.Height 37 | 38 | case messages.NetworkMsg: 39 | m.network = msg 40 | } 41 | 42 | return m, nil 43 | } 44 | 45 | // View is part of the tea.Model interface. 46 | func (m Model) View() string { 47 | 48 | left := m.style.FooterLeft.Render("Algorand Node UI") 49 | //right := m.style.FooterRight.Render(config.GetAlgorandVersion()) 50 | right := m.style.FooterRight.Render(m.network.NodeVersion) 51 | //middleText := fmt.Sprintf("%s (Gensis Hash %s)", m.network.GenesisID, m.network.GenesisHash) 52 | middleText := m.network.GenesisID 53 | 54 | middle := m.style.FooterMiddle.Copy(). 55 | Width(m.width - lipgloss.Width(left) - lipgloss.Width(right)). 56 | Render(middleText) 57 | 58 | return lipgloss.JoinHorizontal(lipgloss.Top, 59 | left, 60 | middle, 61 | right, 62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /tui/internal/bubbles/status/status.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | "github.com/charmbracelet/bubbles/progress" 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/charmbracelet/lipgloss" 13 | 14 | "github.com/algorand/go-algorand-sdk/v2/client/v2/common/models" 15 | "github.com/algorand/go-algorand-sdk/v2/types" 16 | 17 | "github.com/algorand/node-ui/messages" 18 | "github.com/algorand/node-ui/tui/internal/bubbles/explorer" 19 | "github.com/algorand/node-ui/tui/internal/style" 20 | ) 21 | 22 | const roundTo = time.Second / 10 23 | 24 | // consensus constants, in theory these could be modified by a consensus upgrade. 25 | const ( 26 | upgradeVoteRounds = 10000 27 | upgradeThreshold = 9000 28 | ) 29 | 30 | // Model representing the status. 31 | type Model struct { 32 | Status models.NodeStatus 33 | Header types.BlockHeader 34 | Network messages.NetworkMsg 35 | Err error 36 | 37 | style *style.Styles 38 | requestor *messages.Requestor 39 | 40 | // fast catchup state 41 | progress progress.Model 42 | processedAcctsPct float64 43 | verifiedAcctsPct float64 44 | acquiredBlksPct float64 45 | 46 | // round time calculation state 47 | startBlock uint64 48 | startTime time.Time 49 | latestBlock uint64 50 | latestTime time.Time 51 | } 52 | 53 | // New creates a status Model. 54 | func New(style *style.Styles, requestor *messages.Requestor) Model { 55 | return Model{ 56 | style: style, 57 | progress: progress.New(progress.WithDefaultGradient()), 58 | requestor: requestor, 59 | } 60 | } 61 | 62 | // Init is part of the tea.Model interface. 63 | func (m Model) Init() tea.Cmd { 64 | return tea.Batch( 65 | m.requestor.GetNetworkCmd(), 66 | m.requestor.GetStatusCmd(), 67 | ) 68 | } 69 | 70 | func (m Model) averageBlockTime() time.Duration { 71 | numBlocks := int64(m.latestBlock - m.startBlock) 72 | 73 | // Default round time during first seen block 74 | if numBlocks == 0 { 75 | return 4400 * time.Millisecond 76 | } 77 | 78 | runtime := m.latestTime.Sub(m.startTime) 79 | dur := runtime.Nanoseconds() / numBlocks 80 | return time.Duration(dur) 81 | } 82 | 83 | // Update is part of the tea.Model interface. 84 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 85 | switch msg := msg.(type) { 86 | case explorer.BlocksMsg: 87 | // Still initializing. 88 | if m.Status.LastRound == 0 { 89 | return m, nil 90 | } 91 | 92 | for _, blk := range msg.Blocks { 93 | if uint64(blk.Block.Block.Round) == m.Status.LastRound { 94 | m.Header = blk.Block.Block.BlockHeader 95 | } 96 | } 97 | return m, nil 98 | case messages.StatusMsg: 99 | if msg.Error != nil { 100 | m.Err = fmt.Errorf("error fetching status: %w", msg.Error) 101 | return m, tea.Quit 102 | } 103 | m.Status = msg.Status 104 | 105 | // Save the times for computing round time 106 | if m.latestBlock < m.Status.LastRound { 107 | since := time.Duration(m.Status.TimeSinceLastRound) 108 | m.latestBlock = m.Status.LastRound 109 | m.latestTime = time.Now().Add(-since) 110 | 111 | // Grab the start time 112 | if m.startBlock == 0 { 113 | m.startBlock = m.Status.LastRound 114 | m.startTime = m.latestTime 115 | } 116 | } 117 | 118 | if m.Status.CatchpointTotalAccounts > 0 { 119 | m.processedAcctsPct = float64(m.Status.CatchpointProcessedAccounts) / float64(m.Status.CatchpointTotalAccounts) 120 | m.verifiedAcctsPct = float64(m.Status.CatchpointVerifiedAccounts) / float64(m.Status.CatchpointTotalAccounts) 121 | } 122 | if m.Status.CatchpointTotalBlocks > 0 { 123 | m.processedAcctsPct = 1 124 | m.verifiedAcctsPct = 1 125 | m.acquiredBlksPct = float64(m.Status.CatchpointAcquiredBlocks) / float64(m.Status.CatchpointTotalBlocks) 126 | } 127 | 128 | return m, tea.Tick(100*time.Millisecond, func(time.Time) tea.Msg { 129 | return m.requestor.GetStatusCmd()() 130 | }) 131 | 132 | case messages.NetworkMsg: 133 | m.Network = msg 134 | return m, nil 135 | 136 | case progress.FrameMsg: 137 | progressModel, cmd := m.progress.Update(msg) 138 | m.progress = progressModel.(progress.Model) 139 | return m, cmd 140 | 141 | default: 142 | return m, nil 143 | } 144 | } 145 | 146 | func formatVersion(v string) string { 147 | i := strings.LastIndex(v, "/") 148 | if i != 0 { 149 | i++ 150 | } 151 | return v[i:] 152 | } 153 | 154 | func writeProgress(b *strings.Builder, prefix string, progress progress.Model, pct float64) { 155 | b.WriteString(prefix) 156 | b.WriteString(progress.ViewAs(pct)) 157 | b.WriteString("\n") 158 | } 159 | 160 | func (m Model) calculateTimeToGo(start, end uint64, style lipgloss.Style) string { 161 | rounds := end - start 162 | timeRemaining := time.Duration(int64(rounds) * m.averageBlockTime().Nanoseconds()).Round(roundTo) 163 | return style.Render(fmt.Sprintf("%d to go, %s", rounds, timeRemaining)) 164 | } 165 | 166 | // View is part of the tea.Model interface. 167 | func (m Model) View() string { 168 | bold := m.style.StatusBoldText 169 | key := m.style.BottomListItemKey.Copy().MarginLeft(0) 170 | builder := strings.Builder{} 171 | 172 | builder.WriteString(fmt.Sprintf("%s %s\n", bold.Render("Network:"), m.Network.GenesisID)) 173 | builder.WriteString(fmt.Sprintf("%s %s\n", bold.Render("Genesis:"), base64.StdEncoding.EncodeToString(m.Network.GenesisHash[:]))) 174 | // TODO: get rid of magic number 175 | height := style.TopHeight - 2 - 3 // 3 is the padding/margin/border 176 | // status 177 | if (m.Status != models.NodeStatus{}) { 178 | switch { 179 | case m.Status.Catchpoint != "": 180 | // Catchpoint view 181 | builder.WriteString(fmt.Sprintf("\n Catchpoint: %s\n", key.Render(strings.Split(m.Status.Catchpoint, "#")[0]))) 182 | var catchupStatus string 183 | switch { 184 | case m.Status.CatchpointAcquiredBlocks > 0: 185 | catchupStatus = fmt.Sprintf(" Downloading blocks: %5d / %d\n", m.Status.CatchpointAcquiredBlocks, m.Status.CatchpointTotalBlocks) 186 | case m.Status.CatchpointVerifiedAccounts > 0: 187 | catchupStatus = fmt.Sprintf(" Processing accounts: %d / %d\n", m.Status.CatchpointVerifiedAccounts, m.Status.CatchpointTotalAccounts) 188 | case m.Status.CatchpointProcessedAccounts > 0: 189 | catchupStatus = fmt.Sprintf(" Downloading accounts: %d / %d\n", m.Status.CatchpointProcessedAccounts, m.Status.CatchpointTotalAccounts) 190 | default: 191 | catchupStatus = "\n" 192 | } 193 | builder.WriteString(bold.Render(catchupStatus)) 194 | builder.WriteString("\n") 195 | writeProgress(&builder, "Downloading accounts: ", m.progress, m.processedAcctsPct) 196 | writeProgress(&builder, "Processing accounts: ", m.progress, m.verifiedAcctsPct) 197 | writeProgress(&builder, "Downloading blocks: ", m.progress, m.acquiredBlksPct) 198 | height -= 7 199 | default: 200 | builder.WriteString(fmt.Sprintf("Current round: %s\n", key.Render(strconv.FormatUint(m.Status.LastRound, 10)))) 201 | builder.WriteString(fmt.Sprintf("Block wait time: %s\n", time.Duration(m.Status.TimeSinceLastRound).Round(roundTo))) 202 | builder.WriteString(fmt.Sprintf("Sync time: %s\n", time.Duration(m.Status.CatchupTime).Round(roundTo))) 203 | height -= 3 204 | if m.Header.UpgradeState != (types.UpgradeState{}) && (uint64(m.Header.UpgradeState.NextProtocolVoteBefore) > m.Status.LastRound) { 205 | //remainingToUpgrade := m.calculateTimeToGo( 206 | // m.Status.LastRound, uint64(m.Header.NextProtocolSwitchOn), m.style.AccountBlueText) 207 | remainingToVote := m.calculateTimeToGo( 208 | m.Status.LastRound, uint64(m.Header.NextProtocolVoteBefore), m.style.AccountBlueText) 209 | 210 | // calculate yes/no votes 211 | votesToGo := uint64(m.Header.NextProtocolVoteBefore) - m.Status.LastRound 212 | votes := upgradeVoteRounds - votesToGo 213 | voteYes := m.Header.NextProtocolApprovals 214 | voteNo := votes - voteYes 215 | voteString := fmt.Sprintf("%d / %d", voteYes, voteNo) 216 | yesPct := float64(voteYes) / float64(votes) 217 | windowPct := float64(votes) / float64(upgradeVoteRounds) 218 | builder.WriteString(fmt.Sprintf("%s\n", bold.Render("Consensus Upgrade Pending: Votes"))) 219 | builder.WriteString(fmt.Sprintf("Next Protocol: %s\n", formatVersion(m.Header.NextProtocol))) 220 | builder.WriteString(fmt.Sprintf("Yes/No votes: %s (%.0f%%, 90%% required)\n", voteString, yesPct*100)) 221 | //builder.WriteString(fmt.Sprintf("Vote window: %s (%f%%)\n", voteString, *100)) 222 | builder.WriteString(fmt.Sprintf("Vote window close: %d (%.0f%%, %s)\n", 223 | m.Header.UpgradeState.NextProtocolVoteBefore, 224 | windowPct*100, 225 | remainingToVote)) 226 | 227 | height -= 5 228 | } else if m.Status.LastVersion == m.Status.NextVersion { 229 | // no upgrade in progress 230 | builder.WriteString(fmt.Sprintf("Protocol: %s\n", formatVersion(m.Status.LastVersion))) 231 | builder.WriteString(fmt.Sprintf(" %s\n", bold.Render("No upgrade in progress."))) 232 | height -= 2 233 | } else { 234 | // compute the time until the upgrade round and apply formatting to message 235 | togo := m.Status.NextVersionRound - m.Status.LastRound 236 | timeRemaining := time.Duration(int64(togo) * m.averageBlockTime().Nanoseconds()).Round(roundTo) 237 | remaining := m.style.AccountBlueText.Render( 238 | fmt.Sprintf("%d to go, %s", togo, timeRemaining)) 239 | 240 | // upgrade in progress 241 | builder.WriteString(fmt.Sprintf("%s\n", bold.Render("Consensus Upgrade Scheduled"))) 242 | builder.WriteString(fmt.Sprintf("Current Protocol: %s\n", formatVersion(m.Status.LastVersion))) 243 | builder.WriteString(fmt.Sprintf("Next Protocol: %s\n", formatVersion(m.Status.NextVersion))) 244 | builder.WriteString(fmt.Sprintf("Upgrade round: %d (%s)\n", m.Status.NextVersionRound, remaining)) 245 | height -= 4 246 | } 247 | } 248 | } 249 | 250 | // pad the box 251 | for height > 0 { 252 | builder.WriteString("\n") 253 | height-- 254 | } 255 | 256 | return m.style.Status.Render(builder.String()) 257 | } 258 | -------------------------------------------------------------------------------- /tui/internal/bubbles/tabs/tabs.go: -------------------------------------------------------------------------------- 1 | package tabs 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/charmbracelet/lipgloss" 6 | "strings" 7 | ) 8 | 9 | var ( 10 | activeColorForeground = lipgloss.Color("#6dd588") 11 | activeColorBackground = lipgloss.Color("#527772") 12 | 13 | activeTabBorder = lipgloss.Border{ 14 | Top: "─", 15 | Bottom: " ", 16 | Left: "│", 17 | Right: "│", 18 | TopLeft: "╭", 19 | TopRight: "╮", 20 | BottomLeft: "┘", 21 | BottomRight: "└", 22 | } 23 | highlight = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"} 24 | tabBorder = lipgloss.Border{ 25 | Top: "─", 26 | Bottom: "─", 27 | Left: "│ ", 28 | Right: "│", 29 | TopLeft: "╭", 30 | TopRight: "╮", 31 | BottomLeft: "┴", 32 | BottomRight: "┴", 33 | } 34 | tab = lipgloss.NewStyle(). 35 | Border(tabBorder, true). 36 | BorderForeground(highlight). 37 | Padding(0, 2) 38 | activeTab = tab.Copy(). 39 | Border(activeTabBorder, true). 40 | Background(activeColorBackground). 41 | Foreground(activeColorForeground) 42 | 43 | tabGap = tab.Copy(). 44 | BorderTop(false). 45 | BorderLeft(false). 46 | BorderRight(false) 47 | ) 48 | 49 | // Model representing the tabs bubble. 50 | type Model struct { 51 | width int 52 | 53 | index int 54 | tabs []string 55 | 56 | ActiveStyle lipgloss.Style 57 | InactiveStyle lipgloss.Style 58 | } 59 | 60 | // New creates a tabs Model. 61 | // TODO: pass in initial width. 62 | func New(tabs []string) Model { 63 | return Model{ 64 | width: 80, 65 | tabs: tabs, 66 | } 67 | } 68 | 69 | // Height returns the height of this bubble. 70 | func (m Model) Height() int { 71 | return 3 72 | } 73 | 74 | // SetActiveIndex sets the current active index. 75 | func (m *Model) SetActiveIndex(i int) { 76 | m.index = i 77 | } 78 | 79 | // GetActiveIndex returns the current active index. 80 | func (m Model) GetActiveIndex() int { 81 | return m.index 82 | } 83 | 84 | // Init is part of the tea.Model interface. 85 | func (m Model) Init() tea.Cmd { 86 | return nil 87 | } 88 | 89 | // Update is part of the tea.Model interface. 90 | func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { 91 | switch msg := msg.(type) { 92 | case tea.WindowSizeMsg: 93 | m.width = msg.Width 94 | } 95 | 96 | return m, nil 97 | } 98 | 99 | func max(a, b int) int { 100 | if a > b { 101 | return a 102 | } 103 | return b 104 | } 105 | 106 | // View is part of the tea.Model interface. 107 | func (m Model) View() string { 108 | doc := strings.Builder{} 109 | 110 | // Tabs 111 | { 112 | var renderedTabs []string 113 | renderedTabs = append(renderedTabs, "\n"+tabGap.Render(strings.Repeat(" ", 5))) 114 | 115 | // Activate the correct tab 116 | for i, t := range m.tabs { 117 | if i == m.index { 118 | renderedTabs = append(renderedTabs, activeTab.Render(t)) 119 | } else { 120 | renderedTabs = append(renderedTabs, tab.Render(t)) 121 | } 122 | } 123 | 124 | row := lipgloss.JoinHorizontal( 125 | lipgloss.Top, 126 | renderedTabs..., 127 | ) 128 | gap := tabGap.Render(strings.Repeat(" ", max(0, m.width-lipgloss.Width(row)))) 129 | 130 | row = lipgloss.JoinHorizontal(lipgloss.Bottom, row, gap) 131 | doc.WriteString(row) 132 | } 133 | 134 | return doc.String() 135 | } 136 | -------------------------------------------------------------------------------- /tui/internal/bubbles/template/template.go: -------------------------------------------------------------------------------- 1 | // Package template can be used as a starting point when creating a new bubble. 2 | package template 3 | 4 | import ( 5 | tea "github.com/charmbracelet/bubbletea" 6 | ) 7 | 8 | // Model represents a generic bubble 9 | type Model struct { 10 | width int 11 | height int 12 | } 13 | 14 | // New constructs the Model. 15 | func New() Model { 16 | return Model{} 17 | } 18 | 19 | // Init is part of the tea.Model interface. 20 | func (m Model) Init() tea.Cmd { 21 | return nil 22 | } 23 | 24 | // Update is part of the tea.Model interface. 25 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 26 | switch msg := msg.(type) { 27 | case tea.WindowSizeMsg: 28 | m.width = msg.Width 29 | m.height = msg.Height 30 | } 31 | 32 | return m, nil 33 | } 34 | 35 | // View is part of the tea.Model interface. 36 | func (m Model) View() string { 37 | return "template" 38 | } 39 | -------------------------------------------------------------------------------- /tui/internal/constants/keymap.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | import "github.com/charmbracelet/bubbles/key" 4 | 5 | // KeyMap contains references to all the key bindings. 6 | type KeyMap struct { 7 | Generic key.Binding 8 | Quit key.Binding 9 | Catchup key.Binding 10 | AbortCatchup key.Binding 11 | Section key.Binding 12 | Forward key.Binding 13 | Back key.Binding 14 | Help key.Binding 15 | } 16 | 17 | // ShortHelp implements the KeyMap interface. 18 | func (k KeyMap) ShortHelp() []key.Binding { 19 | return []key.Binding{k.Section, k.Forward, k.Back, k.Generic, k.Quit, k.Help} 20 | } 21 | 22 | // FullHelp implements the KeyMap interface. 23 | func (k KeyMap) FullHelp() [][]key.Binding { 24 | return [][]key.Binding{k.ShortHelp()} 25 | } 26 | 27 | // Keys is a global for accessing the KeyMap. 28 | var Keys = KeyMap{ 29 | // Not sure how to group help together. 30 | Generic: key.NewBinding( 31 | key.WithHelp("↑/↓", "navigate")), 32 | Help: key.NewBinding( 33 | key.WithHelp("?", "help")), 34 | Quit: key.NewBinding( 35 | key.WithKeys("q", "ctrl+c"), 36 | key.WithHelp("q", "quit")), 37 | Catchup: key.NewBinding( 38 | key.WithKeys("f"), 39 | key.WithHelp("f", "start fast catchup")), 40 | AbortCatchup: key.NewBinding( 41 | key.WithKeys("a"), 42 | key.WithHelp("a", "abort catchup")), 43 | Section: key.NewBinding( 44 | key.WithKeys("tab"), 45 | key.WithHelp("tab", "section")), 46 | Forward: key.NewBinding( 47 | key.WithKeys("enter", "→"), 48 | key.WithHelp("enter", "forwards")), 49 | Back: key.NewBinding( 50 | key.WithKeys("esc", "←"), 51 | key.WithHelp("esc", "backwards")), 52 | } 53 | -------------------------------------------------------------------------------- /tui/internal/model/init.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | ) 6 | 7 | // Init is part of the tea.Model interface. 8 | func (m Model) Init() tea.Cmd { 9 | return tea.Batch( 10 | tea.EnterAltScreen, 11 | m.Status.Init(), 12 | m.Accounts.Init(), 13 | m.BlockExplorer.Init(), 14 | m.Configs.Init(), 15 | m.Tabs.Init(), 16 | m.About.Init(), 17 | m.Utilities.Init(), 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /tui/internal/model/model.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/help" 5 | tea "github.com/charmbracelet/bubbletea" 6 | 7 | "github.com/algorand/go-algorand-sdk/v2/types" 8 | 9 | "github.com/algorand/node-ui/messages" 10 | "github.com/algorand/node-ui/tui/internal/bubbles/about" 11 | "github.com/algorand/node-ui/tui/internal/bubbles/accounts" 12 | "github.com/algorand/node-ui/tui/internal/bubbles/configs" 13 | "github.com/algorand/node-ui/tui/internal/bubbles/explorer" 14 | "github.com/algorand/node-ui/tui/internal/bubbles/footer" 15 | "github.com/algorand/node-ui/tui/internal/bubbles/status" 16 | "github.com/algorand/node-ui/tui/internal/bubbles/tabs" 17 | "github.com/algorand/node-ui/tui/internal/style" 18 | ) 19 | 20 | const ( 21 | initialWidth = 80 22 | initialHeight = 50 23 | ) 24 | 25 | type activeComponent int 26 | 27 | const ( 28 | explorerTab activeComponent = iota 29 | utilitiesTab 30 | accountTab 31 | configTab 32 | helpTab 33 | ) 34 | 35 | // Model represents the top level of the TUI. 36 | type Model struct { 37 | Status tea.Model 38 | Accounts tea.Model 39 | Tabs tabs.Model 40 | BlockExplorer tea.Model 41 | Configs tea.Model 42 | Utilities tea.Model 43 | About tea.Model 44 | Help help.Model 45 | Footer tea.Model 46 | 47 | network messages.NetworkMsg 48 | 49 | styles *style.Styles 50 | 51 | requestor *messages.Requestor 52 | 53 | active activeComponent 54 | // remember the last resize so we can re-send it when selecting a different bottom component. 55 | lastResize tea.WindowSizeMsg 56 | } 57 | 58 | // New initializes the TUI. 59 | func New(requestor *messages.Requestor, addresses []types.Address) Model { 60 | styles := style.DefaultStyles() 61 | tab := tabs.New([]string{"EXPLORER", "UTILITIES", "ACCOUNTS", "CONFIGURATION", "HELP"}) 62 | // The tab content is the only flexible element. 63 | // This means the height must grow or shrink to fill the available 64 | // window height. It has access to the absolute height but needs to 65 | // be informed about the space used by other elements. 66 | footerHeight := 2 // help + status bar 67 | tabContentMargin := style.TopHeight + tab.Height() + footerHeight 68 | return Model{ 69 | active: explorerTab, 70 | styles: styles, 71 | Status: status.New(styles, requestor), 72 | Tabs: tab, 73 | BlockExplorer: explorer.New(styles, requestor, initialWidth, 0, initialHeight, tabContentMargin), 74 | Configs: configs.New(tabContentMargin), 75 | Accounts: accounts.New(styles, requestor, initialHeight, tabContentMargin, addresses), 76 | Help: help.New(), 77 | Footer: footer.New(styles), 78 | About: about.New(tabContentMargin, about.GetHelpContent()), 79 | Utilities: about.New(tabContentMargin, about.GetUtilsContent()), 80 | requestor: requestor, 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /tui/internal/model/update.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/charmbracelet/bubbles/key" 7 | tea "github.com/charmbracelet/bubbletea" 8 | 9 | "github.com/algorand/node-ui/messages" 10 | "github.com/algorand/node-ui/tui/internal/constants" 11 | ) 12 | 13 | func networkFromID(genesisID string) string { 14 | return strings.Split(genesisID, "-")[0] 15 | } 16 | 17 | // Update is part of the tea.Model interface. 18 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 19 | var ( 20 | cmd tea.Cmd 21 | cmds []tea.Cmd 22 | ) 23 | 24 | switch msg := msg.(type) { 25 | case messages.NetworkMsg: 26 | m.network = msg 27 | 28 | case tea.KeyMsg: 29 | switch { 30 | case key.Matches(msg, constants.Keys.Quit): 31 | return m, tea.Quit 32 | case key.Matches(msg, constants.Keys.Catchup): 33 | return m, messages.StartFastCatchup(networkFromID(m.network.GenesisID)) 34 | case key.Matches(msg, constants.Keys.AbortCatchup): 35 | return m, messages.StopFastCatchup(networkFromID(m.network.GenesisID)) 36 | case key.Matches(msg, constants.Keys.Section): 37 | m.active++ 38 | m.active %= 5 39 | m.Tabs.SetActiveIndex(int(m.active)) 40 | return m, nil 41 | } 42 | switch m.active { 43 | case explorerTab: 44 | var explorerCommand tea.Cmd 45 | m.BlockExplorer, explorerCommand = m.BlockExplorer.Update(msg) 46 | return m, explorerCommand 47 | case accountTab: 48 | case configTab: 49 | case helpTab: 50 | case utilitiesTab: 51 | } 52 | 53 | case tea.WindowSizeMsg: 54 | m.lastResize = msg 55 | } 56 | 57 | m.Status, cmd = m.Status.Update(msg) 58 | cmds = append(cmds, cmd) 59 | 60 | m.Accounts, cmd = m.Accounts.Update(msg) 61 | cmds = append(cmds, cmd) 62 | 63 | m.BlockExplorer, cmd = m.BlockExplorer.Update(msg) 64 | cmds = append(cmds, cmd) 65 | 66 | m.Configs, cmd = m.Configs.Update(msg) 67 | cmds = append(cmds, cmd) 68 | 69 | m.Footer, cmd = m.Footer.Update(msg) 70 | cmds = append(cmds, cmd) 71 | 72 | m.Tabs, cmd = m.Tabs.Update(msg) 73 | cmds = append(cmds, cmd) 74 | 75 | m.About, cmd = m.About.Update(msg) 76 | cmds = append(cmds, cmd) 77 | 78 | m.Utilities, cmd = m.Utilities.Update(msg) 79 | cmds = append(cmds, cmd) 80 | 81 | return m, tea.Batch(cmds...) 82 | } 83 | -------------------------------------------------------------------------------- /tui/internal/model/view.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | "github.com/muesli/reflow/indent" 6 | 7 | "github.com/algorand/node-ui/tui/internal/constants" 8 | ) 9 | 10 | // TODO: this function could implement a type and be passed to the tab view. 11 | func (m Model) tabView() string { 12 | switch activeComponent(m.Tabs.GetActiveIndex()) { 13 | case explorerTab: 14 | return m.BlockExplorer.View() 15 | case accountTab: 16 | return m.Accounts.View() 17 | case configTab: 18 | return m.Configs.View() 19 | case helpTab: 20 | return m.About.View() 21 | case utilitiesTab: 22 | return m.Utilities.View() 23 | } 24 | 25 | return "unknown tab" 26 | } 27 | 28 | // View is part of the tea.Model interface. 29 | func art() string { 30 | // TODO: This could take a width and indent/border to line up with the bottom 31 | art := ` 32 | ▒█████ 33 | ▒████████▒ 34 | ▒████████████▓▓▓▓▒ 35 | ▒█████▒▓████████▓ 36 | ▒█████ ██████▓ 37 | ▒▓████ ▒█████▓ 38 | ▒█████ ▒███████ 39 | ▒█████▓ ▓████████▓ 40 | ▒█████▓ ▓███████████ 41 | ▒█████▒ ██████ ▒█████▓ 42 | ▒█████▒ █████▓ ██████▒ 43 | ▒█████▒ ▒█████▓ ▒██████▒` 44 | return indent.String(art, 3) 45 | } 46 | 47 | // View is part of the tea.Model interface. 48 | func (m Model) View() string { 49 | // Compose the different views by joining them together in the right orientation. 50 | return lipgloss.JoinVertical(0, 51 | lipgloss.JoinHorizontal(0, 52 | m.Status.View(), 53 | art()), 54 | m.Tabs.View(), 55 | m.tabView(), 56 | m.Help.View(constants.Keys), 57 | m.Footer.View()) 58 | } 59 | -------------------------------------------------------------------------------- /tui/internal/style/style.go: -------------------------------------------------------------------------------- 1 | package style 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | ) 6 | 7 | // For now, this is in its own package so that it can be shared between 8 | // different packages without incurring an illegal import cycle. 9 | 10 | const ( 11 | // TopHeight is the hard coded height of the top bubbles. 12 | TopHeight = 13 13 | ) 14 | 15 | // Styles defines styles for the TUI. 16 | type Styles struct { 17 | ActiveBorderColor lipgloss.Color 18 | InactiveBorderColor lipgloss.Color 19 | 20 | // Accounts area 21 | Account lipgloss.Style 22 | AccountBoldText lipgloss.Style 23 | AccountGrayText lipgloss.Style 24 | AccountBlueText lipgloss.Style 25 | AccountYellowText lipgloss.Style 26 | 27 | // Status area 28 | Status lipgloss.Style 29 | StatusBoldText lipgloss.Style 30 | 31 | // Bottom area 32 | Bottom lipgloss.Style 33 | BottomPaginator lipgloss.Style 34 | 35 | BottomListTitle lipgloss.Style 36 | BottomListItemSelector lipgloss.Style 37 | BottomListItemActive lipgloss.Style 38 | BottomListItemInactive lipgloss.Style 39 | BottomListItemKey lipgloss.Style 40 | 41 | // Footer 42 | Footer lipgloss.Style 43 | FooterLeft lipgloss.Style 44 | FooterMiddle lipgloss.Style 45 | FooterRight lipgloss.Style 46 | } 47 | 48 | // DefaultStyles returns default styles for the TUI. 49 | func DefaultStyles() *Styles { 50 | s := new(Styles) 51 | 52 | // used 53 | s.ActiveBorderColor = lipgloss.Color("62") 54 | //s.InactiveBorderColor = lipgloss.Color("236") 55 | s.InactiveBorderColor = lipgloss.Color("#ABB8C3") 56 | s.BottomPaginator = lipgloss.NewStyle(). 57 | Margin(0). 58 | Align(lipgloss.Center) 59 | 60 | // Accounts 61 | s.Account = lipgloss.NewStyle(). 62 | //BorderStyle(lipgloss.RoundedBorder()). 63 | BorderForeground(s.InactiveBorderColor). 64 | Padding(0, 1, 0, 1). 65 | MarginLeft(1) 66 | s.AccountBoldText = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#0693E3")) 67 | s.AccountGrayText = lipgloss.NewStyle().Foreground(lipgloss.Color("#333333")) 68 | s.AccountBlueText = lipgloss.NewStyle().Foreground(lipgloss.Color("#0693E3")) 69 | s.AccountYellowText = lipgloss.NewStyle().Foreground(lipgloss.Color("#A3A322")) 70 | 71 | // Status 72 | s.Status = lipgloss.NewStyle(). 73 | Width(64). 74 | BorderStyle(lipgloss.RoundedBorder()). 75 | BorderForeground(s.InactiveBorderColor). 76 | Padding(0, 1, 0, 1). 77 | MarginLeft(1) 78 | s.StatusBoldText = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#0693E3")) 79 | 80 | // Bottom box 81 | s.Bottom = lipgloss.NewStyle(). 82 | BorderStyle(lipgloss.RoundedBorder()). 83 | BorderForeground(s.InactiveBorderColor). 84 | Padding(1, 2, 1, 1). 85 | MarginLeft(1) 86 | 87 | s.BottomListItemInactive = lipgloss.NewStyle(). 88 | MarginLeft(1) 89 | 90 | s.BottomListTitle = lipgloss.NewStyle(). 91 | //Align(lipgloss.Center). // did not work. 92 | Background(lipgloss.Color("62")). 93 | Foreground(lipgloss.Color("230")). 94 | Padding(0, 1) 95 | 96 | s.BottomListItemSelector = s.BottomListItemInactive.Copy(). 97 | Width(1). 98 | Foreground(lipgloss.Color("#B083EA")) 99 | 100 | s.BottomListItemActive = s.BottomListItemInactive.Copy(). 101 | Bold(true) 102 | 103 | s.BottomListItemKey = s.BottomListItemInactive.Copy(). 104 | Width(10). 105 | Foreground(lipgloss.Color("#A3A322")) 106 | 107 | // Inspired by lipgloss demo 108 | s.Footer = lipgloss.NewStyle(). 109 | Foreground(lipgloss.AdaptiveColor{Light: "#343433", Dark: "#C1C6B2"}). 110 | Background(lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#353533"}) 111 | 112 | s.FooterLeft = lipgloss.NewStyle(). 113 | Inherit(s.Footer). 114 | Foreground(lipgloss.Color("#FFFDF5")). 115 | Background(lipgloss.Color("#4CAF50")). 116 | Padding(0, 1). 117 | MarginRight(1) 118 | s.FooterMiddle = lipgloss.NewStyle(). 119 | Inherit(s.Footer) 120 | 121 | s.FooterRight = lipgloss.NewStyle().Inherit(s.Footer). 122 | Background(lipgloss.Color("#A550DF")). 123 | Padding(0, 1). 124 | Align(lipgloss.Right) 125 | 126 | return s 127 | } 128 | -------------------------------------------------------------------------------- /tui/tui.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "os/signal" 9 | "path" 10 | "syscall" 11 | "time" 12 | 13 | tea "github.com/charmbracelet/bubbletea" 14 | "github.com/charmbracelet/ssh" 15 | "github.com/charmbracelet/wish" 16 | bm "github.com/charmbracelet/wish/bubbletea" 17 | lm "github.com/charmbracelet/wish/logging" 18 | 19 | "github.com/algorand/go-algorand-sdk/v2/types" 20 | 21 | "github.com/algorand/node-ui/messages" 22 | "github.com/algorand/node-ui/tui/internal/model" 23 | ) 24 | 25 | const host = "0.0.0.0" 26 | 27 | func getTeaHandler(model model.Model) bm.Handler { 28 | return func(_ ssh.Session) (tea.Model, []tea.ProgramOption) { 29 | return model, []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseCellMotion()} 30 | } 31 | } 32 | 33 | // Start ... 34 | func Start(port uint64, requestor *messages.Requestor, addresses []types.Address) { 35 | model := model.New(requestor, addresses) 36 | 37 | // Run directly 38 | if port == 0 { 39 | p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion()) 40 | if _, err := p.Run(); err != nil { 41 | fmt.Printf("Error in UI: %v", err) 42 | os.Exit(1) 43 | } 44 | 45 | fmt.Printf("\nUI Terminated, shutting down node.\n") 46 | os.Exit(0) 47 | } 48 | 49 | // Run on ssh server. 50 | dirname, err := os.UserHomeDir() 51 | if err != nil { 52 | log.Fatal(err) 53 | } 54 | 55 | sshServer, err := wish.NewServer( 56 | wish.WithAddress(fmt.Sprintf("%s:%d", host, port)), 57 | wish.WithHostKeyPath(path.Join(dirname, ".ssh/term_info_ed25519")), 58 | wish.WithMiddleware( 59 | bm.Middleware(getTeaHandler(model)), 60 | lm.Middleware(), 61 | ), 62 | ) 63 | if err != nil { 64 | log.Fatalln(err) 65 | } 66 | 67 | done := make(chan os.Signal, 1) 68 | signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) 69 | log.Printf("Starting SSH server on %s:%d", host, port) 70 | go func() { 71 | if err = sshServer.ListenAndServe(); err != nil { 72 | log.Fatalln(err) 73 | } 74 | }() 75 | 76 | <-done 77 | log.Println("Stopping SSH server") 78 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 79 | defer func() { cancel() }() 80 | if err := sshServer.Shutdown(ctx); err != nil { 81 | log.Fatalln(err) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /version/strings.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // These are targets for compiling in build information. 8 | // They are set by the Makefile and .goreleaser.yml 9 | 10 | var ( 11 | // Hash git commit hash. 12 | Hash string 13 | 14 | // ShortHash git commit short hash. 15 | ShortHash string 16 | 17 | // CompileTime of the binary. 18 | CompileTime string 19 | 20 | // ReleaseVersion based on release tag. 21 | ReleaseVersion string 22 | ) 23 | 24 | // UnknownVersion is used when the version is not known. 25 | const UnknownVersion = "(unknown version)" 26 | 27 | // Version the binary version. 28 | func Version() string { 29 | if ReleaseVersion == "" { 30 | return UnknownVersion 31 | } 32 | return ReleaseVersion 33 | } 34 | 35 | // LongVersion the long form of the binary version. 36 | func LongVersion() string { 37 | if ReleaseVersion == "" || Hash == "" || CompileTime == "" { 38 | return UnknownVersion 39 | } 40 | 41 | return fmt.Sprintf("Node UI %s (%s)", ReleaseVersion, ShortHash) 42 | } 43 | --------------------------------------------------------------------------------