refactor: move internal logs pkg from cmd to internal/cli

This commit is contained in:
Pasha Sviderski
2026-04-22 11:27:30 +10:00
parent 0f5f8727a3
commit 19c13f6cd2
5 changed files with 2 additions and 2 deletions
-182
View File
@@ -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,
}
-116
View File
@@ -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
}
-108
View File
@@ -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)
})
}
}
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"