diff --git a/cmd/root.go b/cmd/root.go index b8ddffac..0e409215 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,6 +3,7 @@ package cmd import ( "context" "encoding/json" + "fmt" "github.com/algorandfoundation/hack-tui/api" "github.com/algorandfoundation/hack-tui/internal" "github.com/algorandfoundation/hack-tui/ui" @@ -39,13 +40,25 @@ var ( }, RunE: func(cmd *cobra.Command, args []string) error { log.SetOutput(cmd.OutOrStdout()) + initConfig() + + if viper.GetString("server") == "" { + return fmt.Errorf(style.Red.Render("server is required")) + } + if viper.GetString("token") == "" { + return fmt.Errorf(style.Red.Render("token is required")) + } + client, err := getClient() cobra.CheckErr(err) - + ctx := context.Background() - partkeys, err := internal.GetPartKeys(ctx, client) - cobra.CheckErr(err) + if err != nil { + return fmt.Errorf( + style.Red.Render("failed to get participation keys: %s"), + err) + } state := internal.StateModel{ Status: internal.StatusModel{ @@ -108,7 +121,7 @@ func check(err interface{}) { // Handle global flags and set usage templates func init() { log.SetReportTimestamp(false) - initConfig() + // Configure Version if Version == "" { Version = "unknown (built from source)" @@ -144,6 +157,15 @@ type AlgodConfig struct { EndpointAddress string `json:"EndpointAddress"` } +func replaceEndpointUrl(s string) string { + s = strings.Replace(s, "\n", "", 1) + s = strings.Replace(s, "0.0.0.0", "127.0.0.1", 1) + s = strings.Replace(s, "[::]", "127.0.0.1", 1) + return s +} +func hasWildcardEndpointUrl(s string) bool { + return strings.Contains(s, "0.0.0.0") || strings.Contains(s, "::") +} func initConfig() { // Find home directory. home, err := os.UserHomeDir() @@ -161,12 +183,17 @@ func initConfig() { // Load Configurations viper.AutomaticEnv() - err = viper.ReadInConfig() + _ = viper.ReadInConfig() + + // Check for server + loadedServer := viper.GetString("server") + loadedToken := viper.GetString("token") + // Load ALGORAND_DATA/config.json algorandData, exists := os.LookupEnv("ALGORAND_DATA") // Load the Algorand Data Configuration - if exists && algorandData != "" { + if exists && algorandData != "" && loadedServer == "" { // Placeholder for Struct var algodConfig AlgodConfig @@ -185,23 +212,43 @@ func initConfig() { err = configFile.Close() check(err) - // Replace catchall address with localhost - if strings.Contains(algodConfig.EndpointAddress, "0.0.0.0") { - algodConfig.EndpointAddress = strings.Replace(algodConfig.EndpointAddress, "0.0.0.0", "127.0.0.1", 1) + // Check for endpoint address + if hasWildcardEndpointUrl(algodConfig.EndpointAddress) { + algodConfig.EndpointAddress = replaceEndpointUrl(algodConfig.EndpointAddress) + } else if algodConfig.EndpointAddress == "" { + // Assume it is not set, try to discover the port from the network file + networkPath := algorandData + "/algod.net" + networkFile, err := os.Open(networkPath) + check(err) + + byteValue, err = io.ReadAll(networkFile) + check(err) + + if hasWildcardEndpointUrl(string(byteValue)) { + algodConfig.EndpointAddress = replaceEndpointUrl(string(byteValue)) + } else { + algodConfig.EndpointAddress = string(byteValue) + } + } + if strings.Contains(algodConfig.EndpointAddress, ":0") { + algodConfig.EndpointAddress = strings.Replace(algodConfig.EndpointAddress, ":0", ":8080", 1) + } + if loadedToken == "" { + // Handle Token Path + tokenPath := algorandData + "/algod.admin.token" - // Handle Token Path - tokenPath := algorandData + "/algod.admin.token" + tokenFile, err := os.Open(tokenPath) + check(err) - tokenFile, err := os.Open(tokenPath) - check(err) + byteValue, err = io.ReadAll(tokenFile) + check(err) - byteValue, err = io.ReadAll(tokenFile) - check(err) + viper.Set("token", strings.Replace(string(byteValue), "\n", "", 1)) + } // Set the server configuration - viper.Set("server", "http://"+algodConfig.EndpointAddress) - viper.Set("token", string(byteValue)) + viper.Set("server", "http://"+strings.Replace(algodConfig.EndpointAddress, "\n", "", 1)) viper.Set("data", dataConfigPath) } diff --git a/cmd/root_test.go b/cmd/root_test.go index 0b7b8b7e..7cae6f28 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -21,12 +21,64 @@ func Test_ExecuteRootCommand(t *testing.T) { func Test_InitConfig(t *testing.T) { cwd, _ := os.Getwd() - t.Setenv("ALGORAND_DATA", cwd+"/testdata") + viper.Set("token", "") + viper.Set("server", "") + t.Setenv("ALGORAND_DATA", cwd+"/testdata/Test_InitConfig") initConfig() server := viper.Get("server") if server == "" { t.Fatal("Invalid Server") } + if server != "http://127.0.0.1:8080" { + t.Fatal("Invalid Server") + } +} + +func Test_InitConfigWithoutEndpoint(t *testing.T) { + cwd, _ := os.Getwd() + viper.Set("token", "") + viper.Set("server", "") + t.Setenv("ALGORAND_DATA", cwd+"/testdata/Test_InitConfigWithoutEndpoint") + + initConfig() + server := viper.Get("server") + if server == "" { + t.Fatal("Invalid Server") + } + if server != "http://127.0.0.1:8080" { + t.Fatal("Invalid Server") + } +} + +func Test_InitConfigWithAddress(t *testing.T) { + cwd, _ := os.Getwd() + viper.Set("token", "") + viper.Set("server", "") + t.Setenv("ALGORAND_DATA", cwd+"/testdata/Test_InitConfigWithAddress") + + initConfig() + server := viper.Get("server") + if server == "" { + t.Fatal("Invalid Server") + } + if server != "http://255.255.255.255:8080" { + t.Fatal("Invalid Server") + } +} +func Test_InitConfigWithAddressAndDefaultPort(t *testing.T) { + cwd, _ := os.Getwd() + viper.Set("token", "") + viper.Set("server", "") + t.Setenv("ALGORAND_DATA", cwd+"/testdata/Test_InitConfigWithAddressAndDefaultPort") + + initConfig() + server := viper.Get("server") + if server == "" { + t.Fatal("Invalid Server") + } + if server != "http://255.255.255.255:8080" { + t.Fatal("Invalid Server") + } } diff --git a/cmd/status.go b/cmd/status.go index 3bff01e3..5af7f38b 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -19,6 +19,7 @@ var statusCmd = &cobra.Command{ Short: "Get the node status", Long: style.Purple(BANNER) + "\n" + style.LightBlue("View the node status"), RunE: func(cmd *cobra.Command, args []string) error { + initConfig() if viper.GetString("server") == "" { return errors.New(style.Magenta("server is required")) } diff --git a/cmd/testdata/algod.admin.token b/cmd/testdata/Test_InitConfig/algod.admin.token similarity index 100% rename from cmd/testdata/algod.admin.token rename to cmd/testdata/Test_InitConfig/algod.admin.token diff --git a/cmd/testdata/config.json b/cmd/testdata/Test_InitConfig/config.json similarity index 100% rename from cmd/testdata/config.json rename to cmd/testdata/Test_InitConfig/config.json diff --git a/cmd/testdata/Test_InitConfigWithAddress/algod.admin.token b/cmd/testdata/Test_InitConfigWithAddress/algod.admin.token new file mode 100644 index 00000000..71b7a719 --- /dev/null +++ b/cmd/testdata/Test_InitConfigWithAddress/algod.admin.token @@ -0,0 +1 @@ +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ No newline at end of file diff --git a/cmd/testdata/Test_InitConfigWithAddress/config.json b/cmd/testdata/Test_InitConfigWithAddress/config.json new file mode 100644 index 00000000..f172cd52 --- /dev/null +++ b/cmd/testdata/Test_InitConfigWithAddress/config.json @@ -0,0 +1,4 @@ +{ + "EndpointAddress": "255.255.255.255:8080", + "OtherKey": "" +} \ No newline at end of file diff --git a/cmd/testdata/Test_InitConfigWithAddressAndDefaultPort/algod.admin.token b/cmd/testdata/Test_InitConfigWithAddressAndDefaultPort/algod.admin.token new file mode 100644 index 00000000..71b7a719 --- /dev/null +++ b/cmd/testdata/Test_InitConfigWithAddressAndDefaultPort/algod.admin.token @@ -0,0 +1 @@ +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ No newline at end of file diff --git a/cmd/testdata/Test_InitConfigWithAddressAndDefaultPort/config.json b/cmd/testdata/Test_InitConfigWithAddressAndDefaultPort/config.json new file mode 100644 index 00000000..0e455f6b --- /dev/null +++ b/cmd/testdata/Test_InitConfigWithAddressAndDefaultPort/config.json @@ -0,0 +1,4 @@ +{ + "EndpointAddress": "255.255.255.255:0", + "OtherKey": "" +} \ No newline at end of file diff --git a/cmd/testdata/Test_InitConfigWithoutEndpoint/algod.admin.token b/cmd/testdata/Test_InitConfigWithoutEndpoint/algod.admin.token new file mode 100644 index 00000000..71b7a719 --- /dev/null +++ b/cmd/testdata/Test_InitConfigWithoutEndpoint/algod.admin.token @@ -0,0 +1 @@ +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ No newline at end of file diff --git a/cmd/testdata/Test_InitConfigWithoutEndpoint/algod.net b/cmd/testdata/Test_InitConfigWithoutEndpoint/algod.net new file mode 100644 index 00000000..7985b074 --- /dev/null +++ b/cmd/testdata/Test_InitConfigWithoutEndpoint/algod.net @@ -0,0 +1 @@ +[::]:8080 \ No newline at end of file diff --git a/cmd/testdata/Test_InitConfigWithoutEndpoint/config.json b/cmd/testdata/Test_InitConfigWithoutEndpoint/config.json new file mode 100644 index 00000000..7a73a41b --- /dev/null +++ b/cmd/testdata/Test_InitConfigWithoutEndpoint/config.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/internal/accounts.go b/internal/accounts.go index 7169f828..b7492b03 100644 --- a/internal/accounts.go +++ b/internal/accounts.go @@ -121,18 +121,23 @@ func AccountsFromState(state *StateModel, t Time, client *api.ClientWithResponse for _, key := range *state.ParticipationKeys { val, ok := values[key.Address] if !ok { - - account, err := GetAccount(client, key.Address) - - // TODO: handle error - if err != nil { - // TODO: Logging - panic(err) + var account = api.Account{ + Address: key.Address, + Status: "Unknown", + Amount: 0, } - - var expires = t.Now() + if state.Status.State != "SYNCING" { + var err error + account, err = GetAccount(client, key.Address) + // TODO: handle error + if err != nil { + // TODO: Logging + panic(err) + } + } + now := t.Now() + var expires = now.Add(-(time.Hour * 24 * 365 * 100)) if key.EffectiveLastValid != nil { - now := t.Now() roundDiff := max(0, *key.EffectiveLastValid-int(state.Status.LastRound)) distance := int(state.Metrics.RoundTime) * roundDiff expires = now.Add(time.Duration(distance)) diff --git a/internal/metrics.go b/internal/metrics.go index d2403094..b10a9f40 100644 --- a/internal/metrics.go +++ b/internal/metrics.go @@ -17,6 +17,9 @@ type MetricsModel struct { TPS float64 RX int TX int + LastTS time.Time + LastRX int + LastTX int } type MetricsResponse map[string]int diff --git a/internal/state.go b/internal/state.go index 0bb853cb..62a0dc94 100644 --- a/internal/state.go +++ b/internal/state.go @@ -65,6 +65,11 @@ func (s *StateModel) Watch(cb func(model *StateModel, err error), ctx context.Co // Fetch Keys s.UpdateKeys() + if s.Status.State == "SYNCING" { + lastRound = s.Status.LastRound + cb(s, nil) + continue + } // Run Round Averages and RX/TX every 5 rounds if s.Status.LastRound%5 == 0 { bm, err := GetBlockMetrics(ctx, client, s.Status.LastRound, s.Metrics.Window) @@ -94,8 +99,15 @@ func (s *StateModel) UpdateMetricsFromRPC(ctx context.Context, client *api.Clien } if err == nil { s.Metrics.Enabled = true - s.Metrics.TX = res["algod_network_sent_bytes_total"] - s.Metrics.RX = res["algod_network_received_bytes_total"] + now := time.Now() + diff := now.Sub(s.Metrics.LastTS) + + s.Metrics.TX = max(0, int(float64(res["algod_network_sent_bytes_total"]-s.Metrics.LastTX)/diff.Seconds())) + s.Metrics.RX = max(0, int(float64(res["algod_network_received_bytes_total"]-s.Metrics.LastRX)/diff.Seconds())) + + s.Metrics.LastTS = now + s.Metrics.LastTX = res["algod_network_sent_bytes_total"] + s.Metrics.LastRX = res["algod_network_received_bytes_total"] } } func (s *StateModel) UpdateAccounts() { diff --git a/ui/pages/accounts/controller.go b/ui/pages/accounts/controller.go index 139ee6dc..4e4cf942 100644 --- a/ui/pages/accounts/controller.go +++ b/ui/pages/accounts/controller.go @@ -21,7 +21,7 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { switch msg := msg.(type) { case internal.StateModel: - m.Data = msg.Accounts + m.Data = &msg m.table.SetRows(*m.makeRows()) case tea.KeyMsg: switch msg.String() { diff --git a/ui/pages/accounts/model.go b/ui/pages/accounts/model.go index 0b3c7730..8e1ef3e7 100644 --- a/ui/pages/accounts/model.go +++ b/ui/pages/accounts/model.go @@ -4,6 +4,7 @@ import ( "github.com/algorandfoundation/hack-tui/ui/style" "sort" "strconv" + "time" "github.com/algorandfoundation/hack-tui/internal" "github.com/charmbracelet/bubbles/table" @@ -13,7 +14,7 @@ import ( type ViewModel struct { Width int Height int - Data map[string]internal.Account + Data *internal.StateModel table table.Model navigation string @@ -24,8 +25,8 @@ func New(state *internal.StateModel) ViewModel { m := ViewModel{ Width: 0, Height: 0, - Data: state.Accounts, - controls: "( (g)enerate | enter )", + Data: state, + controls: "( (g)enerate )", navigation: "| " + style.Green.Render("accounts") + " | keys |", } @@ -52,7 +53,7 @@ func (m ViewModel) SelectedAccount() internal.Account { var account internal.Account var selectedRow = m.table.SelectedRow() if selectedRow != nil { - account = m.Data[selectedRow[0]] + account = m.Data.Accounts[selectedRow[0]] } return account } @@ -70,13 +71,20 @@ func (m ViewModel) makeColumns(width int) []table.Column { func (m ViewModel) makeRows() *[]table.Row { rows := make([]table.Row, 0) - for key := range m.Data { + for key := range m.Data.Accounts { + expires := m.Data.Accounts[key].Expires.String() + if m.Data.Status.State == "SYNCING" { + expires = "SYNCING" + } + if !m.Data.Accounts[key].Expires.After(time.Now().Add(-(time.Hour * 24 * 365 * 50))) { + expires = "NA" + } rows = append(rows, table.Row{ - m.Data[key].Address, - strconv.Itoa(m.Data[key].Keys), - m.Data[key].Status, - m.Data[key].Expires.String(), - strconv.Itoa(m.Data[key].Balance), + m.Data.Accounts[key].Address, + strconv.Itoa(m.Data.Accounts[key].Keys), + m.Data.Accounts[key].Status, + expires, + strconv.Itoa(m.Data.Accounts[key].Balance), }) } sort.SliceStable(rows, func(i, j int) bool { diff --git a/ui/status.go b/ui/status.go index 281288bf..6a178eb4 100644 --- a/ui/status.go +++ b/ui/status.go @@ -6,6 +6,7 @@ import ( "github.com/algorandfoundation/hack-tui/ui/style" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "math" "strconv" "strings" "time" @@ -44,6 +45,21 @@ func (m StatusViewModel) HandleMessage(msg tea.Msg) (StatusViewModel, tea.Cmd) { return m, nil } +func getBitRate(bytes int) string { + txString := fmt.Sprintf("%d B/s ", bytes) + if bytes >= 1024 { + txString = fmt.Sprintf("%d KB/s ", bytes/(1<<10)) + } + if bytes >= int(math.Pow(1024, 2)) { + txString = fmt.Sprintf("%d MB/s ", bytes/(1<<20)) + } + if bytes >= int(math.Pow(1024, 3)) { + txString = fmt.Sprintf("%d GB/s ", bytes/(1<<30)) + } + + return txString +} + // View handles the render cycle func (m StatusViewModel) View() string { if !m.IsVisible { @@ -69,14 +85,22 @@ func (m StatusViewModel) View() string { // Last Round row1 := lipgloss.JoinHorizontal(lipgloss.Left, beginning, middle, end) - beginning = style.Blue.Render(" Round time: ") + fmt.Sprintf("%.2fs", float64(m.Data.Metrics.RoundTime)/float64(time.Second)) - end = fmt.Sprintf("%d KB/s ", m.Data.Metrics.TX/1024) + style.Green.Render("TX ") + roundTime := fmt.Sprintf("%.2fs", float64(m.Data.Metrics.RoundTime)/float64(time.Second)) + if m.Data.Status.State == "SYNCING" { + roundTime = "--" + } + beginning = style.Blue.Render(" Round time: ") + roundTime + end = getBitRate(m.Data.Metrics.TX) + style.Green.Render("TX ") middle = strings.Repeat(" ", max(0, size-(lipgloss.Width(beginning)+lipgloss.Width(end)+2))) row2 := lipgloss.JoinHorizontal(lipgloss.Left, beginning, middle, end) - beginning = style.Blue.Render(" TPS: ") + fmt.Sprintf("%.2f", m.Data.Metrics.TPS) - end = fmt.Sprintf("%d KB/s ", m.Data.Metrics.RX/1024) + style.Green.Render("RX ") + tps := fmt.Sprintf("%.2f", m.Data.Metrics.TPS) + if m.Data.Status.State == "SYNCING" { + tps = "--" + } + beginning = style.Blue.Render(" TPS: ") + tps + end = getBitRate(m.Data.Metrics.RX) + style.Green.Render("RX ") middle = strings.Repeat(" ", max(0, size-(lipgloss.Width(beginning)+lipgloss.Width(end)+2))) row3 := lipgloss.JoinHorizontal(lipgloss.Left, beginning, middle, end)