Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion internal/cli/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -81,18 +82,34 @@ 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
if err := dockerClient.Logs(ctx, containerID, logOpts); err != nil {
return fmt.Errorf("failed to retrieve logs: %w", err)
}

// Show streaming footer if following
if follow {
fmt.Println(ui.FormatStreamingFooter())
}

return nil
}

Expand Down
61 changes: 53 additions & 8 deletions internal/docker/container.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package docker

import (
"bufio"
"context"
"fmt"
"io"
Expand All @@ -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"
)

Expand Down Expand Up @@ -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
}

// ============================================================================
Expand Down Expand Up @@ -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
Expand Down
255 changes: 255 additions & 0 deletions internal/ui/logs.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading