Skip to content
26 changes: 12 additions & 14 deletions cmd/harbor/root/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ package root

import (
"fmt"
"io"
"time"
"log/slog"

"github.com/goharbor/harbor-cli/cmd/harbor/root/artifact"
"github.com/goharbor/harbor-cli/cmd/harbor/root/configurations"
Expand All @@ -37,9 +36,10 @@ import (
"github.com/goharbor/harbor-cli/cmd/harbor/root/tag"
"github.com/goharbor/harbor-cli/cmd/harbor/root/user"
"github.com/goharbor/harbor-cli/cmd/harbor/root/webhook"
"github.com/goharbor/harbor-cli/pkg/logger"
"github.com/goharbor/harbor-cli/pkg/utils"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
)

Expand Down Expand Up @@ -68,17 +68,15 @@ harbor help
// Initialize configuration
utils.InitConfig(cfgFile, userSpecifiedConfig)

// Conditionally set the timestamp format only in verbose mode
formatter := &logrus.TextFormatter{}

if verbose {
formatter.FullTimestamp = true
formatter.TimestampFormat = time.RFC3339
logrus.SetLevel(logrus.DebugLevel)
} else {
logrus.SetOutput(io.Discard)
}
logrus.SetFormatter(formatter)
// Sets up logging
logger.Setup(verbose, output)

// Logging Flags
arr := make([]any, 0) // slog requires any since the slog.Debug takes in (string, ...any)
cmd.Flags().VisitAll(func(f *pflag.Flag) {
arr = append(arr, f.Name, f.Value.String())
})
slog.Debug("Flags: ", arr...)

return nil
},
Expand Down
115 changes: 115 additions & 0 deletions pkg/logger/handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// 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 logger

import (
"context"
"fmt"
"io"
"log/slog"

"github.com/charmbracelet/lipgloss"
)

// Needs to be functions, otherwise doesn't work
var (
debugStyle = func() lipgloss.Style { return lipgloss.NewStyle().Foreground(lipgloss.Color("8")) }
infoStyle = func() lipgloss.Style { return lipgloss.NewStyle().Foreground(lipgloss.Color("10")) }
warnStyle = func() lipgloss.Style { return lipgloss.NewStyle().Foreground(lipgloss.Color("11")) }
errorStyle = func() lipgloss.Style { return lipgloss.NewStyle().Foreground(lipgloss.Color("9")) }
)

type PrettyHandler struct {
out io.Writer
level slog.Leveler
}

func NewPrettyHandler(out io.Writer, level slog.Leveler) *PrettyHandler {
return &PrettyHandler{
out: out,
level: level,
}
}

func (h *PrettyHandler) Enabled(_ context.Context, level slog.Level) bool {
return level >= h.level.Level()
}

func (h *PrettyHandler) Handle(_ context.Context, r slog.Record) error {
timestamp := r.Time.Format("15:04:05")
level := formatLevel(r.Level)

// Print main log line
_, err := fmt.Fprintf(
h.out,
"%s %s %s\n",
fmt.Sprintf("%s |", timestamp),
level,
r.Message,
)
if err != nil {
return err
}

var attrs []slog.Attr
maxKey := 0

r.Attrs(func(a slog.Attr) bool {
attrs = append(attrs, a)

if len(a.Key) > maxKey {
maxKey = len(a.Key)
}

return true
})

if len(attrs) == 0 {
return nil
}

// Print attributes
for _, a := range attrs {
_, err = fmt.Fprintf(h.out, " %s : %v\n", fmt.Sprintf("%-*s", maxKey, a.Key),
a.Value.String())
if err != nil {
return err
}
}

return nil
}

func (h *PrettyHandler) WithAttrs(_ []slog.Attr) slog.Handler {
return h
}

func (h *PrettyHandler) WithGroup(_ string) slog.Handler {
return h
}

func formatLevel(level slog.Level) string {
switch level {
case slog.LevelDebug:
return debugStyle().Render("[ DEBUG ]")
case slog.LevelInfo:
return infoStyle().Render("[ INFO ]") // space is intentional for symmetry
case slog.LevelWarn:
return warnStyle().Render("[ WARN ]") // space is intentional for symmetry
case slog.LevelError:
return errorStyle().Render("[ ERROR ]")
default:
return level.String()
}
}
38 changes: 38 additions & 0 deletions pkg/logger/logger.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// 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 logger

import (
"io"
"log/slog"
"os"
)

func Setup(verbose bool, format string) {
outp := io.Discard
if verbose {
outp = os.Stderr
}

var handler slog.Handler
if format == "json" {
handler = slog.NewJSONHandler(outp, &slog.HandlerOptions{Level: slog.LevelDebug})
} else {
// Custom Text Handler
handler = NewPrettyHandler(outp, slog.LevelDebug)
Comment on lines +29 to +33
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The --log-format flag does not validate that the format value is one of the supported options ("json" or "text"). Currently, any invalid value silently defaults to the text handler. Consider adding explicit validation in the Setup function to reject unsupported formats, or document this fallback behavior more clearly. According to the PR discussion, only "json" and "text" should be accepted values.

Suggested change
if format == "json" {
handler = slog.NewJSONHandler(outp, &slog.HandlerOptions{Level: slog.LevelDebug})
} else {
// Custom Text Handler
handler = NewPrettyHandler(outp, slog.LevelDebug)
switch format {
case "json":
handler = slog.NewJSONHandler(outp, &slog.HandlerOptions{Level: slog.LevelDebug})
case "text":
// Custom Text Handler
handler = NewPrettyHandler(outp, slog.LevelDebug)
default:
panic(`unsupported log format: ` + format + `; supported formats are "json" and "text"`)

Copilot uses AI. Check for mistakes.
}

logger := slog.New(handler)
slog.SetDefault(logger)
}
Loading