diff --git a/cmd/harbor/root/logs.go b/cmd/harbor/root/logs.go index dfefcd8cd..ca561d18c 100644 --- a/cmd/harbor/root/logs.go +++ b/cmd/harbor/root/logs.go @@ -34,6 +34,12 @@ func Logs() *cobra.Command { var opts api.ListFlags var follow bool var refreshInterval string + var operationFilter string + var resourceTypeFilter string + var resourceFilter string + var usernameFilter string + var fromTimeFilter string + var toTimeFilter string cmd := &cobra.Command{ Use: "logs", @@ -42,6 +48,15 @@ func Logs() *cobra.Command { Long: `Get recent logs of the projects which the user is a member of. This command retrieves the audit logs for the projects the user is a member of. It supports pagination, sorting, and filtering through query parameters. The logs can be followed in real-time with the --follow flag, and the output can be formatted as JSON with the --output-format flag. +When --page and/or --page-size are explicitly provided, a pagination summary (for example: "Showing 6-10 of 14") is shown in default table output. + +Convenience filter flags are available to build query expressions: +- --operation +- --resource-type +- --resource +- --username +- --from-time and optional --to-time (for op_time range) + harbor-cli logs --page 1 --page-size 10 --query "operation=push" --sort "op_time:desc" harbor-cli logs --follow --refresh-interval 2s @@ -58,9 +73,22 @@ harbor-cli logs --output-format json`, fmt.Println("The --refresh-interval flag is only applicable when using --follow. It will be ignored.") } + query, err := buildAuditLogQuery( + opts.Q, + operationFilter, + resourceTypeFilter, + resourceFilter, + usernameFilter, + fromTimeFilter, + toTimeFilter, + ) + if err != nil { + return err + } + opts.Q = query + if follow { var interval time.Duration = 5 * time.Second - var err error if refreshInterval != "" { interval, err = time.ParseDuration(refreshInterval) if err != nil { @@ -88,6 +116,9 @@ harbor-cli logs --output-format json`, } } else { list.ListLogs(logs.Payload) + if shouldShowPaginationSummary(cmd, "page", "page-size") { + printPaginationSummary(opts.Page, opts.PageSize, int64(len(logs.Payload)), logs.XTotalCount) + } } return nil }, @@ -107,10 +138,203 @@ harbor-cli logs --output-format json`, flags.BoolVarP(&follow, "follow", "f", false, "Follow log output (tail -f behavior)") flags.StringVarP(&refreshInterval, "refresh-interval", "n", "", "Interval to refresh logs when following (default: 5s)") + flags.StringVar(&operationFilter, "operation", "", "Filter by operation") + flags.StringVar(&resourceTypeFilter, "resource-type", "", "Filter by resource type") + flags.StringVar(&resourceFilter, "resource", "", "Filter by resource name") + flags.StringVar(&usernameFilter, "username", "", "Filter by username") + flags.StringVar(&fromTimeFilter, "from-time", "", "Start timestamp for op_time range (RFC3339 or 'YYYY-MM-DD HH:MM:SS'). Required when using --to-time") + flags.StringVar(&toTimeFilter, "to-time", "", "End timestamp for op_time range (RFC3339 or 'YYYY-MM-DD HH:MM:SS'). Optional when --from-time is set; defaults to current time") + + cmd.AddCommand(LogsEventTypesCommand()) return cmd } +func LogsEventTypesCommand() *cobra.Command { + var page int64 + var pageSize int64 + + cmd := &cobra.Command{ + Use: "events", + Short: "List supported Harbor audit log event types", + Long: `List supported Harbor audit log event types. + +By default, all event types are shown. +Use --page and --page-size to paginate the result. + +Examples: + harbor-cli logs events + harbor-cli logs events --page 2 --page-size 5 + harbor-cli logs events --output-format json --page 2 --page-size 5`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + showPaginationSummary := shouldShowPaginationSummary(cmd, "page", "page-size") + if showPaginationSummary { + if page < 1 { + return fmt.Errorf("page number must be greater than or equal to 1") + } + if pageSize < 1 { + return fmt.Errorf("page size must be greater than or equal to 1") + } + if pageSize > 100 { + return fmt.Errorf("page size should be less than or equal to 100") + } + } + + response, err := api.AuditLogEventTypes() + if err != nil { + return fmt.Errorf("failed to retrieve audit log event types: %w", err) + } + + pagedPayload := response.Payload + if showPaginationSummary { + pagedPayload, err = paginateAuditLogEventTypes(response.Payload, page, pageSize) + if err != nil { + return err + } + } + + formatFlag := viper.GetString("output-format") + if formatFlag != "" { + return utils.PrintFormat(pagedPayload, formatFlag) + } + + list.ListAuditLogEventTypes(pagedPayload, page, pageSize, len(response.Payload), showPaginationSummary) + return nil + }, + } + + flags := cmd.Flags() + flags.Int64VarP(&page, "page", "", 1, "Page number") + flags.Int64VarP(&pageSize, "page-size", "", 10, "Size of per page") + + return cmd +} + +func paginateAuditLogEventTypes(eventTypes []*models.AuditLogEventType, page, pageSize int64) ([]*models.AuditLogEventType, error) { + if page < 1 { + return nil, fmt.Errorf("page number must be greater than or equal to 1") + } + if pageSize < 1 { + return nil, fmt.Errorf("page size must be greater than or equal to 1") + } + + start := (page - 1) * pageSize + if start >= int64(len(eventTypes)) { + return []*models.AuditLogEventType{}, nil + } + + end := start + pageSize + if end > int64(len(eventTypes)) { + end = int64(len(eventTypes)) + } + + return eventTypes[start:end], nil +} + +func shouldShowPaginationSummary(cmd *cobra.Command, pageFlagName, pageSizeFlagName string) bool { + return cmd.Flags().Changed(pageFlagName) || cmd.Flags().Changed(pageSizeFlagName) +} + +func printPaginationSummary(page, pageSize, currentCount, totalCount int64) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = currentCount + } + + if totalCount < currentCount { + totalCount = currentCount + } + + if currentCount == 0 { + fmt.Printf("\nShowing 0-0 of %d\n", totalCount) + return + } + + start := (page-1)*pageSize + 1 + end := start + currentCount - 1 + if totalCount > 0 && end > totalCount { + end = totalCount + } + + fmt.Printf("\nShowing %d-%d of %d\n", start, end, totalCount) +} + +func buildAuditLogQuery(baseQuery, operation, resourceType, resource, username, fromTime, toTime string) (string, error) { + parts := []string{} + + baseQuery = strings.TrimSpace(baseQuery) + if baseQuery != "" { + parts = append(parts, baseQuery) + } + + op := strings.TrimSpace(operation) + rt := strings.TrimSpace(resourceType) + res := strings.TrimSpace(resource) + user := strings.TrimSpace(username) + if op != "" { + parts = append(parts, fmt.Sprintf("operation=%s", op)) + } + if rt != "" { + parts = append(parts, fmt.Sprintf("resource_type=%s", rt)) + } + if res != "" { + parts = append(parts, fmt.Sprintf("resource=%s", res)) + } + if user != "" { + parts = append(parts, fmt.Sprintf("username=%s", user)) + } + + from := strings.TrimSpace(fromTime) + to := strings.TrimSpace(toTime) + + // --to-time alone is not allowed; if provided, --from-time must also be present + if from == "" && to != "" { + return "", fmt.Errorf("--to-time cannot be used without --from-time") + } + + // If --from-time is present, use it with either provided --to-time or default to current time + if from != "" { + normalizedFrom, err := normalizeAuditTime(from) + if err != nil { + return "", fmt.Errorf("invalid --from-time: %w", err) + } + + var normalizedTo string + if to == "" { + normalizedTo = time.Now().Format("2006-01-02 15:04:05") + } else { + var err error + normalizedTo, err = normalizeAuditTime(to) + if err != nil { + return "", fmt.Errorf("invalid --to-time: %w", err) + } + } + + parts = append(parts, fmt.Sprintf("op_time=[%s~%s]", normalizedFrom, normalizedTo)) + } + + return strings.Join(parts, ","), nil +} + +func normalizeAuditTime(input string) (string, error) { + layouts := []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02 15:04:05", + } + + for _, layout := range layouts { + if parsed, err := time.Parse(layout, input); err == nil { + return parsed.Format("2006-01-02 15:04:05"), nil + } + } + + return "", fmt.Errorf("expected RFC3339 or 'YYYY-MM-DD HH:MM:SS'") +} + func followLogs(opts api.ListFlags, interval time.Duration) { var lastLogTime *time.Time diff --git a/cmd/harbor/root/logs_test.go b/cmd/harbor/root/logs_test.go new file mode 100644 index 000000000..fb58a37b3 --- /dev/null +++ b/cmd/harbor/root/logs_test.go @@ -0,0 +1,310 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package root + +import ( + "bytes" + "io" + "os" + "regexp" + "testing" + + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" +) + +func TestBuildAuditLogQuery(t *testing.T) { + tests := []struct { + name string + baseQuery string + operation string + resourceType string + resource string + username string + fromTime string + toTime string + expectedRx string // regex pattern to match (for times that vary) + wantErr bool + }{ + { + name: "returns base query only", + baseQuery: "operation=push", + expectedRx: "^operation=push$", + }, + { + name: "builds query with convenience filters", + baseQuery: "operation_result=true", + operation: "create_artifact", + resourceType: "artifact", + resource: "library/nginx", + username: "admin", + expectedRx: "^operation_result=true,operation=create_artifact,resource_type=artifact,resource=library/nginx,username=admin$", + }, + { + name: "builds range query with both times specified", + fromTime: "2025-01-01T01:02:03Z", + toTime: "2025-01-01 05:06:07", + expectedRx: "^op_time=\\[2025-01-01 01:02:03~2025-01-01 05:06:07\\]$", + }, + { + name: "from-time alone defaults to-time to current time", + fromTime: "2025-01-01T01:02:03Z", + expectedRx: "^op_time=\\[2025-01-01 01:02:03~.*\\]$", // matches any end time + }, + { + name: "to-time alone is rejected", + toTime: "2025-01-01 05:06:07", + wantErr: true, + }, + { + name: "fails for invalid from time", + fromTime: "invalid-time", + toTime: "2025-01-01 05:06:07", + wantErr: true, + }, + { + name: "fails for invalid to time", + fromTime: "2025-01-01T01:02:03Z", + toTime: "invalid-time", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + query, err := buildAuditLogQuery( + tt.baseQuery, + tt.operation, + tt.resourceType, + tt.resource, + tt.username, + tt.fromTime, + tt.toTime, + ) + + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + matched, _ := regexp.MatchString(tt.expectedRx, query) + if !matched { + t.Fatalf("expected query to match regex %q, got %q", tt.expectedRx, query) + } + }) + } +} + +func TestPrintPaginationSummary(t *testing.T) { + tests := []struct { + name string + page int64 + pageSize int64 + currentCount int64 + totalCount int64 + expected string + }{ + { + name: "last page with fewer results", + page: 3, + pageSize: 5, + currentCount: 2, + totalCount: 12, + expected: "\nShowing 11-12 of 12\n", + }, + { + name: "current count zero", + page: 2, + pageSize: 10, + currentCount: 0, + totalCount: 14, + expected: "\nShowing 0-0 of 14\n", + }, + { + name: "page size zero uses current count", + page: 2, + pageSize: 0, + currentCount: 3, + totalCount: 20, + expected: "\nShowing 4-6 of 20\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := captureStdout(t, func() { + printPaginationSummary(tt.page, tt.pageSize, tt.currentCount, tt.totalCount) + }) + + if got != tt.expected { + t.Fatalf("expected output %q, got %q", tt.expected, got) + } + }) + } +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + originalStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create stdout pipe: %v", err) + } + + os.Stdout = w + fn() + _ = w.Close() + os.Stdout = originalStdout + + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("failed to read captured stdout: %v", err) + } + _ = r.Close() + + return buf.String() +} + +func TestNormalizeAuditTime(t *testing.T) { + tests := []struct { + name string + input string + expected string + wantErr bool + }{ + { + name: "accepts RFC3339", + input: "2025-01-01T01:02:03Z", + expected: "2025-01-01 01:02:03", + }, + { + name: "accepts RFC3339 with fractional seconds", + input: "2025-01-01T01:02:03.123Z", + expected: "2025-01-01 01:02:03", + }, + { + name: "accepts plain datetime", + input: "2025-01-01 01:02:03", + expected: "2025-01-01 01:02:03", + }, + { + name: "fails for invalid input", + input: "2025/01/01", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := normalizeAuditTime(tt.input) + + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got != tt.expected { + t.Fatalf("expected normalized time %q, got %q", tt.expected, got) + } + }) + } +} + +func TestPaginateAuditLogEventTypes(t *testing.T) { + eventTypes := []*models.AuditLogEventType{ + {EventType: "event-1"}, + {EventType: "event-2"}, + {EventType: "event-3"}, + {EventType: "event-4"}, + } + + tests := []struct { + name string + page int64 + pageSize int64 + wantLen int + firstItem string + wantErr bool + }{ + { + name: "first page", + page: 1, + pageSize: 2, + wantLen: 2, + firstItem: "event-1", + }, + { + name: "second page", + page: 2, + pageSize: 2, + wantLen: 2, + firstItem: "event-3", + }, + { + name: "page out of range", + page: 3, + pageSize: 2, + wantLen: 0, + }, + { + name: "invalid page", + page: 0, + pageSize: 2, + wantErr: true, + }, + { + name: "invalid page size", + page: 1, + pageSize: 0, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := paginateAuditLogEventTypes(eventTypes, tt.page, tt.pageSize) + + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(got) != tt.wantLen { + t.Fatalf("expected len %d, got %d", tt.wantLen, len(got)) + } + + if tt.firstItem != "" && got[0].EventType != tt.firstItem { + t.Fatalf("expected first item %q, got %q", tt.firstItem, got[0].EventType) + } + }) + } +} diff --git a/doc/cli-docs/harbor-logs-events.md b/doc/cli-docs/harbor-logs-events.md new file mode 100644 index 000000000..29cd8af31 --- /dev/null +++ b/doc/cli-docs/harbor-logs-events.md @@ -0,0 +1,46 @@ +--- +title: harbor logs events +weight: 90 +--- +## harbor logs events + +### Description + +##### List supported Harbor audit log event types + +### Synopsis + +List supported Harbor audit log event types. + +By default, all event types are shown. +Use --page and --page-size to paginate the result. + +Examples: + harbor-cli logs events + harbor-cli logs events --page 2 --page-size 5 + harbor-cli logs events --output-format json --page 2 --page-size 5 + +```sh +harbor logs events [flags] +``` + +### Options + +```sh + -h, --help help for events + --page int Page number (default 1) + --page-size int Size of per page (default 10) +``` + +### Options inherited from parent commands + +```sh + -c, --config string config file (default is $HOME/.config/harbor-cli/config.yaml) + -o, --output-format string Output format. One of: json|yaml + -v, --verbose verbose output +``` + +### SEE ALSO + +* [harbor logs](harbor-logs.md) - Get recent logs of the projects which the user is a member of + diff --git a/doc/cli-docs/harbor-logs.md b/doc/cli-docs/harbor-logs.md index 2220e74e0..ceb5e7838 100644 --- a/doc/cli-docs/harbor-logs.md +++ b/doc/cli-docs/harbor-logs.md @@ -13,6 +13,15 @@ weight: 30 Get recent logs of the projects which the user is a member of. This command retrieves the audit logs for the projects the user is a member of. It supports pagination, sorting, and filtering through query parameters. The logs can be followed in real-time with the --follow flag, and the output can be formatted as JSON with the --output-format flag. +When --page and/or --page-size are explicitly provided, a pagination summary (for example: "Showing 6-10 of 14") is shown in default table output. + +Convenience filter flags are available to build query expressions: +- --operation +- --resource-type +- --resource +- --username +- --from-time and optional --to-time (for op_time range) + harbor-cli logs --page 1 --page-size 10 --query "operation=push" --sort "op_time:desc" harbor-cli logs --follow --refresh-interval 2s @@ -27,12 +36,18 @@ harbor logs [flags] ```sh -f, --follow Follow log output (tail -f behavior) + --from-time string Start timestamp for op_time range (RFC3339 or 'YYYY-MM-DD HH:MM:SS'). Required when using --to-time -h, --help help for logs + --operation string Filter by operation --page int Page number (default 1) --page-size int Size of per page (default 10) -q, --query string Query string to query resources -n, --refresh-interval string Interval to refresh logs when following (default: 5s) + --resource string Filter by resource name + --resource-type string Filter by resource type --sort string Sort the resource list in ascending or descending order + --to-time string End timestamp for op_time range (RFC3339 or 'YYYY-MM-DD HH:MM:SS'). Optional when --from-time is set; defaults to current time + --username string Filter by username ``` ### Options inherited from parent commands @@ -46,4 +61,5 @@ harbor logs [flags] ### SEE ALSO * [harbor](harbor.md) - Official Harbor CLI +* [harbor logs events](harbor-logs-events.md) - List supported Harbor audit log event types diff --git a/doc/man-docs/man1/harbor-logs-events.1 b/doc/man-docs/man1/harbor-logs-events.1 new file mode 100644 index 000000000..45f8b9ce0 --- /dev/null +++ b/doc/man-docs/man1/harbor-logs-events.1 @@ -0,0 +1,53 @@ +.nh +.TH "HARBOR" "1" "Harbor Community" "Harbor User Manuals" + +.SH NAME +harbor-logs-events - List supported Harbor audit log event types + + +.SH SYNOPSIS +\fBharbor logs events [flags]\fP + + +.SH DESCRIPTION +List supported Harbor audit log event types. + +.PP +By default, all event types are shown. +Use --page and --page-size to paginate the result. + +.PP +Examples: + harbor-cli logs events + harbor-cli logs events --page 2 --page-size 5 + harbor-cli logs events --output-format json --page 2 --page-size 5 + + +.SH OPTIONS +\fB-h\fP, \fB--help\fP[=false] + help for events + +.PP +\fB--page\fP=1 + Page number + +.PP +\fB--page-size\fP=10 + Size of per page + + +.SH OPTIONS INHERITED FROM PARENT COMMANDS +\fB-c\fP, \fB--config\fP="" + config file (default is $HOME/.config/harbor-cli/config.yaml) + +.PP +\fB-o\fP, \fB--output-format\fP="" + Output format. One of: json|yaml + +.PP +\fB-v\fP, \fB--verbose\fP[=false] + verbose output + + +.SH SEE ALSO +\fBharbor-logs(1)\fP \ No newline at end of file diff --git a/doc/man-docs/man1/harbor-logs.1 b/doc/man-docs/man1/harbor-logs.1 index f9cad239c..542b0d84e 100644 --- a/doc/man-docs/man1/harbor-logs.1 +++ b/doc/man-docs/man1/harbor-logs.1 @@ -13,6 +13,17 @@ harbor-logs - Get recent logs of the projects which the user is a member of Get recent logs of the projects which the user is a member of. This command retrieves the audit logs for the projects the user is a member of. It supports pagination, sorting, and filtering through query parameters. The logs can be followed in real-time with the --follow flag, and the output can be formatted as JSON with the --output-format flag. +.PP +When --page and/or --page-size are explicitly provided, a pagination summary (for example: "Showing 6-10 of 14") is shown in default table output. + +.PP +Convenience filter flags are available to build query expressions: +- --operation +- --resource-type +- --resource +- --username +- --from-time and optional --to-time (for op_time range) + .PP harbor-cli logs --page 1 --page-size 10 --query "operation=push" --sort "op_time:desc" @@ -27,10 +38,18 @@ harbor-cli logs --output-format json \fB-f\fP, \fB--follow\fP[=false] Follow log output (tail -f behavior) +.PP +\fB--from-time\fP="" + Start timestamp for op_time range (RFC3339 or 'YYYY-MM-DD HH:MM:SS'). Required when using --to-time + .PP \fB-h\fP, \fB--help\fP[=false] help for logs +.PP +\fB--operation\fP="" + Filter by operation + .PP \fB--page\fP=1 Page number @@ -47,10 +66,26 @@ harbor-cli logs --output-format json \fB-n\fP, \fB--refresh-interval\fP="" Interval to refresh logs when following (default: 5s) +.PP +\fB--resource\fP="" + Filter by resource name + +.PP +\fB--resource-type\fP="" + Filter by resource type + .PP \fB--sort\fP="" Sort the resource list in ascending or descending order +.PP +\fB--to-time\fP="" + End timestamp for op_time range (RFC3339 or 'YYYY-MM-DD HH:MM:SS'). Optional when --from-time is set; defaults to current time + +.PP +\fB--username\fP="" + Filter by username + .SH OPTIONS INHERITED FROM PARENT COMMANDS \fB-c\fP, \fB--config\fP="" @@ -66,4 +101,4 @@ harbor-cli logs --output-format json .SH SEE ALSO -\fBharbor(1)\fP \ No newline at end of file +\fBharbor(1)\fP, \fBharbor-logs-events(1)\fP \ No newline at end of file diff --git a/pkg/views/logs/view.go b/pkg/views/logs/view.go index af988829c..aacd7b8bd 100644 --- a/pkg/views/logs/view.go +++ b/pkg/views/logs/view.go @@ -16,6 +16,8 @@ package list import ( "fmt" "os" + "strconv" + "strings" "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" @@ -32,6 +34,11 @@ var columns = []table.Column{ {Title: "Time", Width: 16}, } +var eventTypeColumns = []table.Column{ + {Title: "INDEX", Width: tablelist.WidthS}, + {Title: "EVENT_TYPE", Width: 40}, +} + func ListLogs(logs []*models.AuditLogExt) { var rows []table.Row for _, log := range logs { @@ -52,3 +59,51 @@ func ListLogs(logs []*models.AuditLogExt) { os.Exit(1) } } + +func ListAuditLogEventTypes(eventTypes []*models.AuditLogEventType, page, pageSize int64, total int, showPaginationSummary bool) { + if len(eventTypes) == 0 { + if showPaginationSummary { + fmt.Println("No audit log event types found for the requested page.") + return + } + fmt.Println("No audit log event types found.") + return + } + + startIndex := int64(1) + if showPaginationSummary { + startIndex = (page-1)*pageSize + 1 + } + + var rows []table.Row + for i, eventType := range eventTypes { + rows = append(rows, table.Row{ + strconv.FormatInt(startIndex+int64(i), 10), + auditLogEventTypeName(eventType), + }) + } + + m := tablelist.NewModel(eventTypeColumns, rows, len(rows)) + if _, err := tea.NewProgram(m).Run(); err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } + + if showPaginationSummary { + endIndex := startIndex + int64(len(eventTypes)) - 1 + fmt.Printf("\nShowing %d-%d of %d\n", startIndex, endIndex, total) + } +} + +func auditLogEventTypeName(eventType *models.AuditLogEventType) string { + if eventType == nil { + return "-" + } + + name := strings.TrimSpace(eventType.EventType) + if name == "" { + return "-" + } + + return name +}