From 9259f7bf97ec7c2f5d90f00f570b771c4f077e67 Mon Sep 17 00:00:00 2001 From: Harry Dhillon Date: Thu, 16 Oct 2025 00:01:53 -0600 Subject: [PATCH] feat: Add log formatting and styled output for the `ork logs ` command - Introduce log line formatting with timestamp handling and level-based styling. - Enhance `logs` command to support custom formatters for container logs. - Add `ui` package for reusable log styling utilities, service headers, and streaming indicators. - Refactor Docker log streaming logic to include optional line-by-line processing with formatters. Signed-off-by: Harry Dhillon --- internal/cli/logs.go | 19 ++- internal/docker/container.go | 61 +++++++-- internal/ui/logs.go | 255 +++++++++++++++++++++++++++++++++++ 3 files changed, 326 insertions(+), 9 deletions(-) create mode 100644 internal/ui/logs.go diff --git a/internal/cli/logs.go b/internal/cli/logs.go index 6f94de3..fae594b 100644 --- a/internal/cli/logs.go +++ b/internal/cli/logs.go @@ -6,6 +6,7 @@ import ( "github.com/ork-cli/ork/internal/config" "github.com/ork-cli/ork/internal/docker" + "github.com/ork-cli/ork/internal/ui" "github.com/spf13/cobra" ) @@ -81,11 +82,22 @@ func runLogs(serviceName string, follow bool, tail string, timestamps bool) erro return err } - // Build log options + // Print a beautiful service header + header := ui.FormatServiceHeader(serviceName, containerID, follow) + fmt.Println(header) + ui.EmptyLine() + + // Create a formatter that applies log level coloring + logFormatter := func(line string) string { + return ui.FormatLogLine(line, timestamps) + } + + // Build log options with formatter logOpts := docker.LogsOptions{ Follow: follow, Tail: tail, Timestamps: timestamps, + Formatter: logFormatter, } // Stream logs @@ -93,6 +105,11 @@ func runLogs(serviceName string, follow bool, tail string, timestamps bool) erro return fmt.Errorf("failed to retrieve logs: %w", err) } + // Show streaming footer if following + if follow { + fmt.Println(ui.FormatStreamingFooter()) + } + return nil } diff --git a/internal/docker/container.go b/internal/docker/container.go index 9b042fb..c0f6f14 100644 --- a/internal/docker/container.go +++ b/internal/docker/container.go @@ -1,6 +1,7 @@ package docker import ( + "bufio" "context" "fmt" "io" @@ -9,6 +10,7 @@ import ( "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/image" + "github.com/docker/docker/pkg/stdcopy" "github.com/docker/go-connections/nat" ) @@ -47,9 +49,10 @@ type ContainerInfo struct { // LogsOptions contains configuration for retrieving container logs type LogsOptions struct { - Follow bool // Stream logs continuously (like tail -f) - Tail string // Number of lines to show from the end ("all" or "100") - Timestamps bool // Show timestamps in log output + Follow bool // Stream logs continuously (like tail -f) + Tail string // Number of lines to show from the end ("all" or "100") + Timestamps bool // Show timestamps in log output + Formatter func(string) string // Optional: format each log line before output } // ============================================================================ @@ -187,11 +190,53 @@ func (c *Client) Logs(ctx context.Context, containerID string, opts LogsOptions) } }() - // Stream logs to stdout - // Docker multiplexes stdout/stderr into the reader, so we just copy it all - _, err = io.Copy(os.Stdout, reader) - if err != nil && err != io.EOF { - return fmt.Errorf("failed to stream logs: %w", err) + // If no formatter is provided, just demultiplex and copy to stdout (legacy behavior) + if opts.Formatter == nil { + _, err = stdcopy.StdCopy(os.Stdout, os.Stderr, reader) + if err != nil && err != io.EOF { + return fmt.Errorf("failed to stream logs: %w", err) + } + return nil + } + + // With formatter: demultiplex streams and process line by line + // Create a pipe to capture the demultiplexed output + pr, pw := io.Pipe() + + // Start demultiplexing in a goroutine + demuxErr := make(chan error, 1) + go func() { + _, err := stdcopy.StdCopy(pw, pw, reader) + if closeErr := pw.Close(); closeErr != nil && err == nil { + // Only report a close error if there wasn't already a demux error + err = fmt.Errorf("failed to close pipe writer: %w", closeErr) + } + demuxErr <- err + }() + + // Process demultiplexed output line by line + scanner := bufio.NewScanner(pr) + // Increase the buffer size for long log lines (default is 64KB, set to 1MB) + const maxLogLineLength = 1024 * 1024 + buf := make([]byte, maxLogLineLength) + scanner.Buffer(buf, maxLogLineLength) + + for scanner.Scan() { + line := scanner.Text() + + // Apply formatter and print + formattedLine := opts.Formatter(line) + fmt.Println(formattedLine) + } + + // Check for scanner errors + if err := scanner.Err(); err != nil && err != io.EOF { + return fmt.Errorf("failed to read logs: %w", err) + } + + // Check for demux errors + if err := <-demuxErr; err != nil && err != io.EOF { + return fmt.Errorf("failed to demultiplex logs: %w", err) } return nil diff --git a/internal/ui/logs.go b/internal/ui/logs.go new file mode 100644 index 0000000..a98b243 --- /dev/null +++ b/internal/ui/logs.go @@ -0,0 +1,255 @@ +package ui + +import ( + "regexp" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// ============================================================================ +// Log Level Detection +// ============================================================================ + +// LogLevel represents the severity of a log message +type LogLevel int + +const ( + LogLevelUnknown LogLevel = iota + LogLevelTrace + LogLevelDebug + LogLevelInfo + LogLevelWarn + LogLevelError + LogLevelFatal +) + +// Common log level patterns - matches various formats: +// - ERROR, error, Error +// - [ERROR], [error] +// - level=error, level="error" +// - "level":"error" +// - ERROR:, error: +var ( + errorPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)\b(error|err|fatal|panic|critical|crit)\b`), + regexp.MustCompile(`(?i)\[(error|err|fatal|panic|critical|crit)\]`), + regexp.MustCompile(`(?i)level[=:]\s*"?(error|err|fatal|panic|critical|crit)"?`), + regexp.MustCompile(`(?i)"level"\s*:\s*"(error|err|fatal|panic|critical|crit)"`), + } + + warnPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)\b(warn|warning|alert)\b`), + regexp.MustCompile(`(?i)\[(warn|warning|alert)\]`), + regexp.MustCompile(`(?i)level[=:]\s*"?(warn|warning|alert)"?`), + regexp.MustCompile(`(?i)"level"\s*:\s*"(warn|warning|alert)"`), + } + + infoPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)\b(info|information|notice)\b`), + regexp.MustCompile(`(?i)\[(info|information|notice)\]`), + regexp.MustCompile(`(?i)level[=:]\s*"?(info|information|notice)"?`), + regexp.MustCompile(`(?i)"level"\s*:\s*"(info|information|notice)"`), + } + + debugPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)\b(debug|dbg|trace|verbose)\b`), + regexp.MustCompile(`(?i)\[(debug|dbg|trace|verbose)\]`), + regexp.MustCompile(`(?i)level[=:]\s*"?(debug|dbg|trace|verbose)"?`), + regexp.MustCompile(`(?i)"level"\s*:\s*"(debug|dbg|trace|verbose)"`), + } +) + +// detectLogLevel analyzes a log line and returns its detected level +func detectLogLevel(line string) LogLevel { + // Check error patterns first (highest priority) + for _, pattern := range errorPatterns { + if pattern.MatchString(line) { + return LogLevelError + } + } + + // Check warning patterns + for _, pattern := range warnPatterns { + if pattern.MatchString(line) { + return LogLevelWarn + } + } + + // Check info patterns + for _, pattern := range infoPatterns { + if pattern.MatchString(line) { + return LogLevelInfo + } + } + + // Check debug patterns + for _, pattern := range debugPatterns { + if pattern.MatchString(line) { + return LogLevelDebug + } + } + + // Default to unknown + return LogLevelUnknown +} + +// ============================================================================ +// Log Formatting Styles +// ============================================================================ + +var ( + // Log level colors + logErrorStyle = lipgloss.NewStyle().Foreground(ColorError) + logWarnStyle = lipgloss.NewStyle().Foreground(ColorWarning) + logInfoStyle = lipgloss.NewStyle().Foreground(ColorInfo) + logDebugStyle = lipgloss.NewStyle().Foreground(ColorTextDim) + logTraceStyle = lipgloss.NewStyle().Foreground(ColorTextDim).Faint(true) + + // Timestamp style - dim and gray + timestampStyle = lipgloss.NewStyle(). + Foreground(ColorTextDim). + Faint(true) + + // Service header styles + serviceHeaderStyle = lipgloss.NewStyle(). + Foreground(ColorPrimary). + Bold(true). + Padding(0, 1). + Border(lipgloss.RoundedBorder()). + BorderForeground(ColorPrimary) + + containerIDStyle = lipgloss.NewStyle(). + Foreground(ColorTextDim). + Faint(true) + + streamingIndicatorStyle = lipgloss.NewStyle(). + Foreground(ColorSecondary). + Bold(true) +) + +// ============================================================================ +// Log Formatters +// ============================================================================ + +// FormatServiceHeader formats a header for log output showing service name and container ID +func FormatServiceHeader(serviceName, containerID string, isStreaming bool) string { + var parts []string + + // Service name in a box + header := serviceHeaderStyle.Render(SymbolPackage + " " + serviceName) + parts = append(parts, header) + + // Container ID (shortened to 12 chars like Docker does) + if len(containerID) > 12 { + containerID = containerID[:12] + } + idText := containerIDStyle.Render("container: " + containerID) + parts = append(parts, idText) + + // Streaming indicator if following + if isStreaming { + indicator := streamingIndicatorStyle.Render("● streaming") + parts = append(parts, indicator) + } + + return strings.Join(parts, " ") +} + +// FormatLogLine formats a single log line with appropriate color coding +func FormatLogLine(line string, showTimestamps bool) string { + if line == "" { + return "" + } + + // Detect log level from the original line + level := detectLogLevel(line) + + // Extract timestamp and content separately + var styledTimestamp string + var content string + + if showTimestamps { + // Extract the timestamp and keep it separate + timestamp, rest := extractTimestamp(line) + if timestamp != "" { + styledTimestamp = timestampStyle.Render(timestamp) + " " + content = rest + } else { + content = line + } + } else { + // Remove timestamps if present + content = stripTimestamp(line) + } + + // Apply color to content based on the log level + var styledContent string + switch level { + case LogLevelError, LogLevelFatal: + styledContent = logErrorStyle.Render(content) + case LogLevelWarn: + styledContent = logWarnStyle.Render(content) + case LogLevelInfo: + styledContent = logInfoStyle.Render(content) + case LogLevelDebug, LogLevelTrace: + styledContent = logDebugStyle.Render(content) + default: + // No special formatting for unknown level + styledContent = content + } + + // Combine styled timestamp with styled content + return styledTimestamp + styledContent +} + +// ============================================================================ +// Timestamp Handling +// ============================================================================ + +// Common timestamp patterns at the start of log lines +var timestampPatterns = []*regexp.Regexp{ + // ISO 8601 timestamps: 2024-01-15T10:30:45Z, 2024-01-15T10:30:45.123Z + regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z?\s+`), + // RFC3339: 2024-01-15 10:30:45 + regexp.MustCompile(`^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(\.\d+)?\s+`), + // Docker timestamps: 2024-01-15T10:30:45.123456789Z + regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\s+`), + // Short timestamps: 10:30:45 + regexp.MustCompile(`^\d{2}:\d{2}:\d{2}(\.\d+)?\s+`), + // Timestamps in brackets: [2024-01-15 10:30:45] + regexp.MustCompile(`^\[\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(\.\d+)?\]\s+`), +} + +// extractTimestamp extracts the timestamp from the beginning of a log line +// Returns the timestamp and the rest of the line separately +func extractTimestamp(line string) (timestamp string, rest string) { + for _, pattern := range timestampPatterns { + matches := pattern.FindStringSubmatch(line) + if len(matches) > 0 { + timestamp = strings.TrimSpace(matches[0]) + rest = strings.TrimPrefix(line, matches[0]) + return timestamp, rest + } + } + return "", line +} + +// stripTimestamp removes timestamps from the beginning of log lines +func stripTimestamp(line string) string { + for _, pattern := range timestampPatterns { + if pattern.MatchString(line) { + return pattern.ReplaceAllString(line, "") + } + } + return line +} + +// ============================================================================ +// Stream State Indicator +// ============================================================================ + +// FormatStreamingFooter shows a footer when streaming is active +func FormatStreamingFooter() string { + return StyleDim.Render("\n" + SymbolInfo + " Press Ctrl+C to stop streaming") +}