mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03:34 +00:00
refactor: move internal logs pkg from cmd to internal/cli
This commit is contained in:
@@ -1,182 +0,0 @@
|
||||
package logs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"image/color"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/docker/docker/pkg/stringid"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
)
|
||||
|
||||
// Formatter handles formatting and printing of log entries with dynamic column alignment.
|
||||
type Formatter struct {
|
||||
machineNames []string
|
||||
serviceNames []string
|
||||
|
||||
maxMachineWidth int
|
||||
maxServiceWidth int
|
||||
|
||||
utc bool
|
||||
}
|
||||
|
||||
func NewFormatter(machineNames, serviceNames []string, utc bool) *Formatter {
|
||||
slices.Sort(machineNames)
|
||||
slices.Sort(serviceNames)
|
||||
|
||||
maxMachineWidth := 0
|
||||
for _, name := range machineNames {
|
||||
if len(name) > maxMachineWidth {
|
||||
maxMachineWidth = len(name)
|
||||
}
|
||||
}
|
||||
|
||||
maxServiceWidth := 0
|
||||
for _, name := range serviceNames {
|
||||
if len(name) > maxServiceWidth {
|
||||
maxServiceWidth = len(name)
|
||||
}
|
||||
}
|
||||
|
||||
return &Formatter{
|
||||
machineNames: machineNames,
|
||||
serviceNames: serviceNames,
|
||||
maxMachineWidth: maxMachineWidth,
|
||||
maxServiceWidth: maxServiceWidth,
|
||||
utc: utc,
|
||||
}
|
||||
}
|
||||
|
||||
// formatTimestamp formats timestamp using local timezone or UTC if configured.
|
||||
func (f *Formatter) formatTimestamp(t time.Time) string {
|
||||
if f.utc {
|
||||
t = t.UTC()
|
||||
} else {
|
||||
t = t.In(time.Local)
|
||||
}
|
||||
dimStyle := lipgloss.NewStyle().Faint(true)
|
||||
|
||||
return dimStyle.Render(t.Format(time.StampMilli))
|
||||
}
|
||||
|
||||
func (f *Formatter) formatMachine(name string) string {
|
||||
style := lipgloss.NewStyle().Bold(true).PaddingRight(f.maxMachineWidth - len(name))
|
||||
|
||||
if len(f.serviceNames) == 1 {
|
||||
// Machine name is coloured for single-service logs.
|
||||
i := slices.Index(f.machineNames, name)
|
||||
if i == -1 {
|
||||
f.machineNames = append(f.machineNames, name)
|
||||
i = len(f.machineNames) - 1
|
||||
}
|
||||
|
||||
style = style.Foreground(palette[i%len(palette)])
|
||||
}
|
||||
|
||||
return style.Render(name)
|
||||
}
|
||||
|
||||
func (f *Formatter) formatService(serviceName, containerID string) string {
|
||||
styleService := lipgloss.NewStyle().Bold(true)
|
||||
padding := f.maxServiceWidth - len(serviceName)
|
||||
|
||||
if len(f.serviceNames) > 1 {
|
||||
// Service name is coloured for multi-service logs.
|
||||
i := slices.Index(f.serviceNames, serviceName)
|
||||
if i == -1 {
|
||||
f.serviceNames = append(f.serviceNames, serviceName)
|
||||
i = len(f.serviceNames) - 1
|
||||
}
|
||||
|
||||
styleService = styleService.Foreground(palette[i%len(palette)])
|
||||
}
|
||||
|
||||
// Journal logs are unit-scoped and have no container ID.
|
||||
if containerID == "" {
|
||||
return styleService.PaddingRight(padding).Render(serviceName)
|
||||
}
|
||||
|
||||
return styleService.Render(serviceName) + tui.Faint.PaddingRight(padding).Render("/"+containerID[:5])
|
||||
}
|
||||
|
||||
// PrintEntry prints a single log entry with proper formatting.
|
||||
func (f *Formatter) PrintEntry(entry api.ServiceLogEntry) {
|
||||
if entry.Stream != api.LogStreamStdout && entry.Stream != api.LogStreamStderr {
|
||||
return
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
|
||||
// Timestamp
|
||||
output.WriteString(f.formatTimestamp(entry.Timestamp))
|
||||
output.WriteString(" ")
|
||||
|
||||
// Machine name
|
||||
output.WriteString(f.formatMachine(entry.Metadata.MachineName))
|
||||
output.WriteString(" ")
|
||||
|
||||
// Service/container_id or service name for a systemd service.
|
||||
output.WriteString(f.formatService(entry.Metadata.ServiceName, entry.Metadata.ContainerID))
|
||||
output.WriteString(" ")
|
||||
|
||||
// Message
|
||||
output.Write(entry.Message)
|
||||
|
||||
// Print to appropriate stream.
|
||||
if entry.Stream == api.LogStreamStderr {
|
||||
fmt.Fprint(os.Stderr, output.String())
|
||||
} else {
|
||||
fmt.Print(output.String())
|
||||
}
|
||||
}
|
||||
|
||||
// PrintError prints an error entry (e.g., stalled stream warning).
|
||||
func (f *Formatter) PrintError(entry api.ServiceLogEntry) {
|
||||
if entry.Metadata.ServiceName == "" {
|
||||
msg := fmt.Sprintf("ERROR: %v", entry.Err)
|
||||
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.BrightRed) // Bold bright red.
|
||||
fmt.Fprintln(os.Stderr, style.Render(msg))
|
||||
return
|
||||
}
|
||||
|
||||
var msg string
|
||||
if entry.Metadata.ContainerID != "" {
|
||||
msg = fmt.Sprintf("WARNING: log stream from container '%s/%s' on machine '%s'",
|
||||
entry.Metadata.ServiceName,
|
||||
stringid.TruncateID(entry.Metadata.ContainerID),
|
||||
entry.Metadata.MachineName)
|
||||
} else {
|
||||
msg = fmt.Sprintf("WARNING: log stream from systemd service '%s' on machine '%s'",
|
||||
entry.Metadata.ServiceName,
|
||||
entry.Metadata.MachineName)
|
||||
}
|
||||
|
||||
if errors.Is(entry.Err, api.ErrLogStreamStalled) {
|
||||
msg += " stopped responding"
|
||||
} else {
|
||||
msg += fmt.Sprintf(": %v", entry.Err)
|
||||
}
|
||||
|
||||
style := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11")) // Bold bright yellow.
|
||||
fmt.Fprintln(os.Stderr, style.Render(msg))
|
||||
}
|
||||
|
||||
// palette is available colors for machine/service differentiation.
|
||||
var palette = []color.Color{
|
||||
lipgloss.BrightGreen,
|
||||
lipgloss.BrightYellow,
|
||||
lipgloss.BrightBlue,
|
||||
lipgloss.BrightMagenta,
|
||||
lipgloss.BrightCyan,
|
||||
lipgloss.Green,
|
||||
lipgloss.Yellow,
|
||||
lipgloss.Blue,
|
||||
lipgloss.Magenta,
|
||||
lipgloss.Cyan,
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package logs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// Options describes how and what logs we are requesting.
|
||||
type Options struct {
|
||||
Files []string
|
||||
Follow bool
|
||||
Tail string
|
||||
Since string
|
||||
Until string
|
||||
UTC bool
|
||||
Machines []string
|
||||
}
|
||||
|
||||
func Flags(options *Options) *pflag.FlagSet {
|
||||
set := &pflag.FlagSet{}
|
||||
|
||||
set.BoolVarP(&options.Follow, "follow", "f", false,
|
||||
"Continually stream new logs.")
|
||||
set.StringSliceVarP(&options.Machines, "machine", "m", nil,
|
||||
"Filter logs by machine name or ID. Can be specified multiple times or as a comma-separated list.")
|
||||
set.StringVar(&options.Since, "since", "",
|
||||
"Show logs generated on or after the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
|
||||
"Examples:\n"+
|
||||
" --since 2m30s Relative duration (2 minutes 30 seconds ago)\n"+
|
||||
" --since 1h Relative duration (1 hour ago)\n"+
|
||||
" --since 2025-11-24 RFC 3339 date only (midnight using local timezone)\n"+
|
||||
" --since 2024-05-14T22:50:00 RFC 3339 date/time using local timezone\n"+
|
||||
" --since 2024-01-31T10:30:00Z RFC 3339 date/time in UTC\n"+
|
||||
" --since 1763953966 Unix timestamp (seconds since January 1, 1970)")
|
||||
set.StringVarP(&options.Tail, "tail", "n", "100",
|
||||
"Show the most recent logs and limit the number of lines shown per replica. Use 'all' to show all logs.")
|
||||
set.StringVar(&options.Until, "until", "",
|
||||
"Show logs generated before the given timestamp. Accepts relative duration, RFC 3339 date, or Unix timestamp.\n"+
|
||||
"See --since for examples.")
|
||||
set.BoolVar(&options.UTC, "utc", false,
|
||||
"Print timestamps in UTC instead of local timezone.")
|
||||
|
||||
return set
|
||||
}
|
||||
|
||||
func Tail(tail string) (int, error) {
|
||||
if tail == "all" {
|
||||
return -1, nil
|
||||
}
|
||||
|
||||
tailInt, err := strconv.Atoi(tail)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid --tail value '%s': %w", tail, err)
|
||||
}
|
||||
return tailInt, nil
|
||||
}
|
||||
|
||||
// ServiceArg pairs a service name with the list of container filters parsed from `uc service logs` arguments.
|
||||
// An empty Containers slice means "stream logs from all containers of this service".
|
||||
type ServiceArg struct {
|
||||
Service string
|
||||
Containers []string
|
||||
}
|
||||
|
||||
// ParseServiceArgs groups raw `uc service logs` positional arguments into per-service entries,
|
||||
// preserving first-seen order. Each argument is either a service name (e.g. "web") or a
|
||||
// service/container reference (e.g. "web/61d57fd3428f").
|
||||
// Arguments for the same service are merged: if any argument for a service lacks a container suffix, all containers
|
||||
// of that service are streamed regardless of any other service/container arguments for it.
|
||||
func ParseServiceArgs(args []string) ([]ServiceArg, error) {
|
||||
indexByService := make(map[string]int, len(args))
|
||||
allContainers := make(map[string]bool, len(args))
|
||||
result := make([]ServiceArg, 0, len(args))
|
||||
|
||||
for _, arg := range args {
|
||||
arg = strings.TrimSpace(arg)
|
||||
if arg == "" {
|
||||
return nil, fmt.Errorf("empty service argument")
|
||||
}
|
||||
|
||||
service, container, hasSlash := strings.Cut(arg, "/")
|
||||
if service == "" {
|
||||
return nil, fmt.Errorf("invalid service argument '%s': service name is empty", arg)
|
||||
}
|
||||
if hasSlash && container == "" {
|
||||
return nil, fmt.Errorf("invalid service argument '%s': container name or ID is empty", arg)
|
||||
}
|
||||
|
||||
idx, seen := indexByService[service]
|
||||
if !seen {
|
||||
entry := ServiceArg{Service: service}
|
||||
if hasSlash {
|
||||
entry.Containers = []string{container}
|
||||
} else {
|
||||
allContainers[service] = true
|
||||
}
|
||||
result = append(result, entry)
|
||||
indexByService[service] = len(result) - 1
|
||||
continue
|
||||
}
|
||||
|
||||
if !hasSlash {
|
||||
result[idx].Containers = nil
|
||||
allContainers[service] = true
|
||||
continue
|
||||
}
|
||||
if !allContainers[service] {
|
||||
result[idx].Containers = append(result[idx].Containers, container)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
package logs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseServiceArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want []ServiceArg
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "empty input",
|
||||
args: nil,
|
||||
want: []ServiceArg{},
|
||||
},
|
||||
{
|
||||
name: "single bareword service",
|
||||
args: []string{"web"},
|
||||
want: []ServiceArg{{Service: "web"}},
|
||||
},
|
||||
{
|
||||
name: "single container",
|
||||
args: []string{"web/abc123"},
|
||||
want: []ServiceArg{{Service: "web", Containers: []string{"abc123"}}},
|
||||
},
|
||||
{
|
||||
name: "multiple containers same service",
|
||||
args: []string{"web/abc123", "web/def456"},
|
||||
want: []ServiceArg{{Service: "web", Containers: []string{"abc123", "def456"}}},
|
||||
},
|
||||
{
|
||||
name: "multiple services interleaved",
|
||||
args: []string{"web/abc123", "api", " web/def456 ", "db/xyz789 "},
|
||||
want: []ServiceArg{
|
||||
{Service: "web", Containers: []string{"abc123", "def456"}},
|
||||
{Service: "api"},
|
||||
{Service: "db", Containers: []string{"xyz789"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bareword wins when seen first",
|
||||
args: []string{"web", "web/abc123"},
|
||||
want: []ServiceArg{{Service: "web"}},
|
||||
},
|
||||
{
|
||||
name: "bareword wins when seen later",
|
||||
args: []string{"web/abc123", "web/def456", "web"},
|
||||
want: []ServiceArg{{Service: "web"}},
|
||||
},
|
||||
{
|
||||
name: "bareword wins followed by more container args",
|
||||
args: []string{"web/abc123", "web", "web/def456"},
|
||||
want: []ServiceArg{{Service: "web"}},
|
||||
},
|
||||
{
|
||||
name: "preserves first-seen service order",
|
||||
args: []string{"db", "api", "web"},
|
||||
want: []ServiceArg{
|
||||
{Service: "db"},
|
||||
{Service: "api"},
|
||||
{Service: "web"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty arg",
|
||||
args: []string{""},
|
||||
wantErr: "empty service argument",
|
||||
},
|
||||
{
|
||||
name: "empty spaces arg",
|
||||
args: []string{" "},
|
||||
wantErr: "empty service argument",
|
||||
},
|
||||
{
|
||||
name: "missing service half",
|
||||
args: []string{"/abc123"},
|
||||
wantErr: "service name is empty",
|
||||
},
|
||||
{
|
||||
name: "missing container half",
|
||||
args: []string{"web/"},
|
||||
wantErr: "container name or ID is empty",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := ParseServiceArgs(tt.args)
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/psviderski/uncloud/cmd/uncloud/internal/logs"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/logs"
|
||||
"github.com/psviderski/uncloud/internal/journal"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"strings"
|
||||
|
||||
mapset "github.com/deckarep/golang-set/v2"
|
||||
"github.com/psviderski/uncloud/cmd/uncloud/internal/logs"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/logs"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
|
||||
Reference in New Issue
Block a user