From 52ebb4d2b53d5868e427c4dc6f4560bb6187fbb3 Mon Sep 17 00:00:00 2001 From: mahbub570 Date: Thu, 15 Aug 2024 03:06:49 +0600 Subject: [PATCH 1/2] Implement UserGroup Command in CLI Signed-off-by: mahbub570 --- cmd/harbor/root/cmd.go | 2 + cmd/harbor/root/usergroup/cmd.go | 26 ++++++ cmd/harbor/root/usergroup/create.go | 99 ++++++++++++++++++++++ cmd/harbor/root/usergroup/delete.go | 70 ++++++++++++++++ cmd/harbor/root/usergroup/get.go | 58 +++++++++++++ cmd/harbor/root/usergroup/list.go | 28 +++++++ cmd/harbor/root/usergroup/search.go | 57 +++++++++++++ cmd/harbor/root/usergroup/update.go | 36 ++++++++ pkg/api/usergroup_handler.go | 123 ++++++++++++++++++++++++++++ pkg/views/usergroup/get/view.go | 43 ++++++++++ pkg/views/usergroup/list/view.go | 45 ++++++++++ pkg/views/usergroup/search/view.go | 49 +++++++++++ pkg/views/usergroup/update/view.go | 64 +++++++++++++++ 13 files changed, 700 insertions(+) create mode 100644 cmd/harbor/root/usergroup/cmd.go create mode 100644 cmd/harbor/root/usergroup/create.go create mode 100644 cmd/harbor/root/usergroup/delete.go create mode 100644 cmd/harbor/root/usergroup/get.go create mode 100644 cmd/harbor/root/usergroup/list.go create mode 100644 cmd/harbor/root/usergroup/search.go create mode 100644 cmd/harbor/root/usergroup/update.go create mode 100644 pkg/api/usergroup_handler.go create mode 100644 pkg/views/usergroup/get/view.go create mode 100644 pkg/views/usergroup/list/view.go create mode 100644 pkg/views/usergroup/search/view.go create mode 100644 pkg/views/usergroup/update/view.go diff --git a/cmd/harbor/root/cmd.go b/cmd/harbor/root/cmd.go index 7a3fbf3d..53023902 100644 --- a/cmd/harbor/root/cmd.go +++ b/cmd/harbor/root/cmd.go @@ -8,6 +8,7 @@ import ( "github.com/goharbor/harbor-cli/cmd/harbor/root/artifact" "github.com/goharbor/harbor-cli/cmd/harbor/root/project" "github.com/goharbor/harbor-cli/cmd/harbor/root/registry" + "github.com/goharbor/harbor-cli/cmd/harbor/root/usergroup" repositry "github.com/goharbor/harbor-cli/cmd/harbor/root/repository" "github.com/goharbor/harbor-cli/cmd/harbor/root/user" "github.com/goharbor/harbor-cli/pkg/utils" @@ -109,6 +110,7 @@ harbor help repositry.Repository(), user.User(), artifact.Artifact(), + usergroup.Usergroup(), ) return root diff --git a/cmd/harbor/root/usergroup/cmd.go b/cmd/harbor/root/usergroup/cmd.go new file mode 100644 index 00000000..85c0b467 --- /dev/null +++ b/cmd/harbor/root/usergroup/cmd.go @@ -0,0 +1,26 @@ +package usergroup + +import ( + "github.com/spf13/cobra" +) + +func Usergroup() *cobra.Command { + cmd := &cobra.Command{ + Use: "usergroup", + Short: "Manage usergroup", + Long: `Manage usergroup in Harbor`, + Example: ` harbor usergroup list`, + } + + cmd.AddCommand( + UserGroupCreateCommand(), + UserGroupsListCommand(), + UserGroupDeleteCommand(), + UserGroupsSearchCommand(), + UserGroupUpdateCommand(), + UserGroupGetCommand(), + + ) + + return cmd +} diff --git a/cmd/harbor/root/usergroup/create.go b/cmd/harbor/root/usergroup/create.go new file mode 100644 index 00000000..262e7393 --- /dev/null +++ b/cmd/harbor/root/usergroup/create.go @@ -0,0 +1,99 @@ +package usergroup + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/spf13/cobra" +) + +type ErrorResponse struct { + Errors []struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"errors"` +} + +func UserGroupCreateCommand() *cobra.Command { + var groupName string + var groupType int64 + var ldapGroupDn string + + cmd := &cobra.Command{ + Use: "create", + Short: "create user group", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if groupName == "" { + fmt.Print("Enter group name: ") + fmt.Scanln(&groupName) + } + + for { + if groupType == 0 { + fmt.Print("Enter group type (1 for LDAP, 2 for HTTP, 3 for OIDC group): ") + var input string + fmt.Scanln(&input) + var err error + groupType, err = strconv.ParseInt(input, 10, 64) + if err != nil { + fmt.Println("Invalid input, please enter an integer.") + groupType = 0 + continue + } + } + + if groupType < 1 || groupType > 3 { + fmt.Println("Invalid group type. Must be 1 (LDAP), 2 (HTTP), or 3 (OIDC).") + groupType = 0 + continue + } + + if groupType == 1 { + fmt.Print("Enter the DN of the LDAP group: ") + fmt.Scanln(&ldapGroupDn) + } + + break + } + + var ldapInfo string + if groupType == 1 { + ldapInfo = fmt.Sprintf(", LDAP DN: %s", ldapGroupDn) + } + + fmt.Printf("Creating user group with name: %s, type: %d%s\n", groupName, groupType, ldapInfo) + err := api.CreateUserGroup(groupName, groupType, ldapGroupDn) + if err != nil { + return formatError(err) + } + + fmt.Printf("User group '%s' created successfully\n", groupName) + return nil + }, + } + + flags := cmd.Flags() + flags.StringVarP(&groupName, "name", "n", "", "Group name") + flags.Int64VarP(&groupType, "type", "t", 0, "Group type") + flags.StringVarP(&ldapGroupDn, "ldap-dn", "l", "", "The DN of the LDAP group if group type is 1 (LDAP group)") + + return cmd +} + +func formatError(err error) error { + errStr := err.Error() + if strings.Contains(errStr, "conflict:") { + var errResp ErrorResponse + jsonStr := strings.TrimPrefix(errStr, "conflict: ") + if err := json.Unmarshal([]byte(jsonStr), &errResp); err == nil { + if len(errResp.Errors) > 0 { + return fmt.Errorf("%s", errResp.Errors[0].Message) + } + } + } + return fmt.Errorf("failed to create user group: %v", err) +} \ No newline at end of file diff --git a/cmd/harbor/root/usergroup/delete.go b/cmd/harbor/root/usergroup/delete.go new file mode 100644 index 00000000..7bb34254 --- /dev/null +++ b/cmd/harbor/root/usergroup/delete.go @@ -0,0 +1,70 @@ +package usergroup + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" + + "github.com/goharbor/harbor-cli/pkg/api" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +func UserGroupDeleteCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete [groupID]", + Short: "delete user group", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + var groupId int64 + var err error + + if len(args) == 0 { + fmt.Print("Enter group ID: ") + reader := bufio.NewReader(os.Stdin) + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(input) + groupId, err = strconv.ParseInt(input, 10, 64) + if err != nil { + log.Errorf("invalid group ID: %v", err) + return + } + } else { + groupId, err = strconv.ParseInt(args[0], 10, 64) + if err != nil { + log.Errorf("invalid group ID: %v", err) + return + } + } + response, err := api.ListUserGroups() + if err != nil { + log.Errorf("failed to list user groups: %v", err) + return + } + + groupExists := false + for _, group := range response.Payload { + if group.ID == groupId { + groupExists = true + break + } + } + + if !groupExists { + log.Errorf("group ID %d not found", groupId) + return + } + + err = api.DeleteUserGroup(groupId) + if err != nil { + log.Errorf("failed to delete user group: %v", err) + } + fmt.Print("\033[K") + + }, + } + + return cmd +} diff --git a/cmd/harbor/root/usergroup/get.go b/cmd/harbor/root/usergroup/get.go new file mode 100644 index 00000000..978eec5a --- /dev/null +++ b/cmd/harbor/root/usergroup/get.go @@ -0,0 +1,58 @@ +package usergroup + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" + + "github.com/goharbor/harbor-cli/pkg/api" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + get "github.com/goharbor/harbor-cli/pkg/views/usergroup/get" +) + +func UserGroupGetCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "get [groupID]", + Short: "get user group details", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + var groupId int64 + var err error + + if len(args) == 0 { + fmt.Print("Enter group ID: ") + reader := bufio.NewReader(os.Stdin) + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(input) + groupId, err = strconv.ParseInt(input, 10, 64) + if err != nil { + log.Errorf("invalid group ID: %v", err) + return + } + } else { + groupId, err = strconv.ParseInt(args[0], 10, 64) + if err != nil { + log.Errorf("invalid group ID: %v", err) + return + } + } + + response, err := api.GetUserGroup(groupId) + if err != nil { + if strings.Contains(err.Error(), "404") { + log.Errorf("user group not found with id %d", groupId) + } else { + log.Errorf("failed to get user group: %v", err) + } + return + } + + get.DisplayUserGroup(response.Payload) + }, + } + + return cmd +} diff --git a/cmd/harbor/root/usergroup/list.go b/cmd/harbor/root/usergroup/list.go new file mode 100644 index 00000000..59683451 --- /dev/null +++ b/cmd/harbor/root/usergroup/list.go @@ -0,0 +1,28 @@ +package usergroup + +import ( + "github.com/goharbor/harbor-cli/pkg/api" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + list "github.com/goharbor/harbor-cli/pkg/views/usergroup/list" +) + +func UserGroupsListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "list user groups", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + userGroups, err := api.ListUserGroups() + if err != nil { + log.Errorf("failed to list user groups: %v", err) + return + } + + list.ListUserGroups(userGroups) + }, + } + + return cmd +} diff --git a/cmd/harbor/root/usergroup/search.go b/cmd/harbor/root/usergroup/search.go new file mode 100644 index 00000000..8a430f2b --- /dev/null +++ b/cmd/harbor/root/usergroup/search.go @@ -0,0 +1,57 @@ +package usergroup + +import ( + "bufio" + "fmt" + "os" + "strings" + + search "github.com/goharbor/harbor-cli/pkg/views/usergroup/search" + "github.com/goharbor/harbor-cli/pkg/api" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +func UserGroupsSearchCommand() *cobra.Command { + var groupName string + + cmd := &cobra.Command{ + Use: "search [groupName]", + Short: "search user groups", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + if len(args) > 0 { + groupName = args[0] + } + + if groupName == "" { + fmt.Print("Enter group name: ") + reader := bufio.NewReader(os.Stdin) + input, _ := reader.ReadString('\n') + groupName = strings.TrimSpace(input) + } + fmt.Print("\033[K") + + fmt.Printf("Searching for groups with name '%s'...\r", groupName) + response, err := api.SearchUserGroups(groupName) + if err != nil { + log.Errorf("failed to search user groups: %v", err) + return + } + + + if len(response.Payload) == 0 { + log.Infof("No user groups found with the name %s", groupName) + return + } + + search.DisplayUserGroupSearchResults(response) + + }, + } + + flags := cmd.Flags() + flags.StringVarP(&groupName, "name", "n", "", "Group name to search") + + return cmd +} diff --git a/cmd/harbor/root/usergroup/update.go b/cmd/harbor/root/usergroup/update.go new file mode 100644 index 00000000..14fb4efd --- /dev/null +++ b/cmd/harbor/root/usergroup/update.go @@ -0,0 +1,36 @@ +package usergroup + +import ( + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/goharbor/harbor-cli/pkg/views/usergroup/update" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +func UserGroupUpdateCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "update", + Short: "update user group", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + userGroupsResp, err := api.ListUserGroups() + if err != nil { + log.Errorf("failed to list user groups: %v", err) + return + } + input, err := update.UpdateUserGroupView(userGroupsResp) + if err != nil { + log.Errorf("failed to get user input: %v", err) + return + } + err = api.UpdateUserGroup(input.GroupID, input.GroupName, input.GroupType) + if err != nil { + log.Errorf("failed to update user group: %v", err) + } else { + log.Infof("User group `%s` updated successfully", input.GroupName) + } + }, + } + + return cmd +} \ No newline at end of file diff --git a/pkg/api/usergroup_handler.go b/pkg/api/usergroup_handler.go new file mode 100644 index 00000000..d333bed4 --- /dev/null +++ b/pkg/api/usergroup_handler.go @@ -0,0 +1,123 @@ +package api + +import ( + "github.com/goharbor/go-client/pkg/sdk/v2.0/client/usergroup" + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" + "github.com/goharbor/harbor-cli/pkg/utils" +"fmt" + log "github.com/sirupsen/logrus" + "encoding/json" +) + + +func CreateUserGroup(groupName string, groupType int64, ldapGroupDn string) error { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return fmt.Errorf("failed to create client: %v", err) + } + + userGroup := &models.UserGroup{ + GroupName: groupName, + GroupType: groupType, + } + + if groupType == 1 { + userGroup.LdapGroupDn = ldapGroupDn + } + + _, err = client.Usergroup.CreateUserGroup(ctx, &usergroup.CreateUserGroupParams{ + Usergroup: userGroup, + }) + + if err != nil { + switch e := err.(type) { + case *usergroup.CreateUserGroupBadRequest: + payload, _ := json.MarshalIndent(e.Payload, "", " ") + return fmt.Errorf("bad request: %s", string(payload)) + case *usergroup.CreateUserGroupConflict: + payload, _ := json.MarshalIndent(e.Payload, "", " ") + return fmt.Errorf("conflict: %s", string(payload)) + default: + return fmt.Errorf("failed to create user group: %v", err) + } + } + + return nil + +} + +func DeleteUserGroup(groupId int64) error { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return err + } + + _, err = client.Usergroup.DeleteUserGroup(ctx, &usergroup.DeleteUserGroupParams{GroupID: groupId}) + if err != nil { + return err + } + log.Infof("User group deleted successfully with id %d", groupId) + return nil +} + +func GetUserGroup(groupId int64) (*usergroup.GetUserGroupOK, error) { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return nil, err + } + + response, err := client.Usergroup.GetUserGroup(ctx, &usergroup.GetUserGroupParams{GroupID: groupId}) + if err != nil { + return nil, err + } + + return response, nil +} + +func ListUserGroups() (*usergroup.ListUserGroupsOK, error) { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return nil, err + } + + response, err := client.Usergroup.ListUserGroups(ctx, &usergroup.ListUserGroupsParams{}) + if err != nil { + return nil, err + } + + return response, nil +} + +func SearchUserGroups(groupName string) (*usergroup.SearchUserGroupsOK, error) { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return nil, err + } + + response, err := client.Usergroup.SearchUserGroups(ctx, &usergroup.SearchUserGroupsParams{Groupname: groupName}) + if err != nil { + return nil, err + } + + return response, nil +} + +func UpdateUserGroup(groupId int64, groupName string, groupType int64) error { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return err + } + + _, err = client.Usergroup.UpdateUserGroup(ctx, &usergroup.UpdateUserGroupParams{ + GroupID: groupId, + Usergroup: &models.UserGroup{ + GroupName: groupName, + GroupType: groupType, + }, + }) + + if err != nil { + return err + } + return nil +} \ No newline at end of file diff --git a/pkg/views/usergroup/get/view.go b/pkg/views/usergroup/get/view.go new file mode 100644 index 00000000..bc6cd348 --- /dev/null +++ b/pkg/views/usergroup/get/view.go @@ -0,0 +1,43 @@ +package get + +import ( + "fmt" + "os" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" + "github.com/goharbor/harbor-cli/pkg/views/base/tablelist" +) + +var columns = []table.Column{ + {Title: "Field", Width: 20}, + {Title: "Value", Width: 40}, +} + +func DisplayUserGroup(group *models.UserGroup) { + rows := []table.Row{ + {"ID", fmt.Sprintf("%d", group.ID)}, + {"Group Name", group.GroupName}, + {"Group Type", getGroupTypeString(group.GroupType)}, + } + + m := tablelist.NewModel(columns, rows, len(rows)) + if _, err := tea.NewProgram(m).Run(); err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } +} + +func getGroupTypeString(groupType int64) string { + switch groupType { + case 1: + return "LDAP" + case 2: + return "HTTP" + case 3: + return "OIDC" + default: + return "Unknown" + } +} \ No newline at end of file diff --git a/pkg/views/usergroup/list/view.go b/pkg/views/usergroup/list/view.go new file mode 100644 index 00000000..315bc6ab --- /dev/null +++ b/pkg/views/usergroup/list/view.go @@ -0,0 +1,45 @@ +package list + +import ( + "fmt" + "os" + "strconv" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/goharbor/go-client/pkg/sdk/v2.0/client/usergroup" + "github.com/goharbor/harbor-cli/pkg/views/base/tablelist" +) + +var columns = []table.Column{ + {Title: "ID", Width: 10}, + {Title: "Group Name", Width: 30}, + {Title: "Group Type", Width: 15}, +} + +func ListUserGroups(resp *usergroup.ListUserGroupsOK) { + var rows []table.Row + for _, group := range resp.Payload { + groupType := "Unknown" + switch group.GroupType { + case 1: + groupType = "LDAP" + case 2: + groupType = "HTTP" + case 3: + groupType = "OIDC" + } + + rows = append(rows, table.Row{ + strconv.Itoa(int(group.ID)), + group.GroupName, + groupType, + }) + } + + m := tablelist.NewModel(columns, rows, len(rows)) + if _, err := tea.NewProgram(m).Run(); err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } +} \ No newline at end of file diff --git a/pkg/views/usergroup/search/view.go b/pkg/views/usergroup/search/view.go new file mode 100644 index 00000000..50a24450 --- /dev/null +++ b/pkg/views/usergroup/search/view.go @@ -0,0 +1,49 @@ +package search + +import ( + "fmt" + "os" + "strconv" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/goharbor/go-client/pkg/sdk/v2.0/client/usergroup" + "github.com/goharbor/harbor-cli/pkg/views/base/tablelist" +) + +var columns = []table.Column{ + {Title: "ID", Width: 10}, + {Title: "Group Name", Width: 30}, + {Title: "Group Type", Width: 15}, +} + +func DisplayUserGroupSearchResults(resp *usergroup.SearchUserGroupsOK) { + var rows []table.Row + for _, group := range resp.Payload { + groupType := getGroupTypeString(group.GroupType) + rows = append(rows, table.Row{ + strconv.Itoa(int(group.ID)), + group.GroupName, + groupType, + }) + } + + m := tablelist.NewModel(columns, rows, len(rows)) + if _, err := tea.NewProgram(m).Run(); err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } +} + +func getGroupTypeString(groupType int64) string { + switch groupType { + case 1: + return "LDAP" + case 2: + return "HTTP" + case 3: + return "OIDC" + default: + return "Unknown" + } +} \ No newline at end of file diff --git a/pkg/views/usergroup/update/view.go b/pkg/views/usergroup/update/view.go new file mode 100644 index 00000000..7f5bc8fe --- /dev/null +++ b/pkg/views/usergroup/update/view.go @@ -0,0 +1,64 @@ +package update + +import ( + "fmt" + "strconv" + + sdkUserGroup "github.com/goharbor/go-client/pkg/sdk/v2.0/client/usergroup" + + "github.com/charmbracelet/huh" + +) + +type UpdateUserGroupInput struct { + GroupID int64 + GroupName string + GroupType int64 +} + +func UpdateUserGroupView(userGroups *sdkUserGroup.ListUserGroupsOK) (*UpdateUserGroupInput, error) { + var options []huh.Option[string] + for _, ug := range userGroups.Payload { + option := huh.NewOption(fmt.Sprintf("%d - %s", ug.ID, ug.GroupName), strconv.FormatInt(ug.ID, 10)) + options = append(options, option) + } + + var selectedGroupID string + var groupName string + var groupType int64 + + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Select a user group to update"). + Options(options...). + Value(&selectedGroupID), + huh.NewInput(). + Title("Enter new group name"). + Value(&groupName), + huh.NewSelect[int64](). + Title("Select group type"). + Options( + huh.NewOption("LDAP", int64(1)), + huh.NewOption("HTTP", int64(2)), + huh.NewOption("OIDC", int64(3)), + ). + Value(&groupType), + ), + ) + + err := form.Run() + if err != nil { + return nil, fmt.Errorf("form input error: %v", err) + } + groupID, err := strconv.ParseInt(selectedGroupID, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid group ID: %v", err) + } + + return &UpdateUserGroupInput{ + GroupID: groupID, + GroupName: groupName, + GroupType: groupType, + }, nil +} \ No newline at end of file From b893b43159ec9e6956e9ef5b4b8f65c0e7304b32 Mon Sep 17 00:00:00 2001 From: mahbub570 Date: Sun, 18 Aug 2024 19:39:06 +0600 Subject: [PATCH 2/2] fixed output table of usergroup get command Signed-off-by: mahbub570 --- pkg/views/usergroup/get/view.go | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/pkg/views/usergroup/get/view.go b/pkg/views/usergroup/get/view.go index bc6cd348..2260f165 100644 --- a/pkg/views/usergroup/get/view.go +++ b/pkg/views/usergroup/get/view.go @@ -10,16 +10,14 @@ import ( "github.com/goharbor/harbor-cli/pkg/views/base/tablelist" ) -var columns = []table.Column{ - {Title: "Field", Width: 20}, - {Title: "Value", Width: 40}, -} - func DisplayUserGroup(group *models.UserGroup) { + columns := []table.Column{ + {Title: "Group Name", Width: 30}, + {Title: "Group Type", Width: 20}, + } + rows := []table.Row{ - {"ID", fmt.Sprintf("%d", group.ID)}, - {"Group Name", group.GroupName}, - {"Group Type", getGroupTypeString(group.GroupType)}, + {group.GroupName, getGroupTypeString(group.GroupType)}, } m := tablelist.NewModel(columns, rows, len(rows))