diff --git a/cmd/handler.go b/cmd/handler.go index a25cf4b..d7f4ea5 100644 --- a/cmd/handler.go +++ b/cmd/handler.go @@ -38,6 +38,7 @@ func (k *KsctlCommand) CommandMapping() error { k.Connect(), k.ScaleUp(), k.ScaleDown(), + k.Summary(), ) cli.RegisterCommand( diff --git a/cmd/summary.go b/cmd/summary.go new file mode 100644 index 0000000..6f089fd --- /dev/null +++ b/cmd/summary.go @@ -0,0 +1,105 @@ +package cmd + +import ( + "os" + "strconv" + + "github.com/ksctl/cli/v2/pkg/cli" + "github.com/ksctl/cli/v2/pkg/telemetry" + "github.com/ksctl/ksctl/v2/pkg/handler/cluster/common" + "github.com/ksctl/ksctl/v2/pkg/handler/cluster/controller" + "github.com/spf13/cobra" +) + +func (k *KsctlCommand) Summary() *cobra.Command { + + cmd := &cobra.Command{ + Use: "summary", + Example: ` +ksctl cluster summary --help + `, + Short: "Use to get summary of the created cluster", + Long: "It is used to get summary cluster", + + Run: func(cmd *cobra.Command, args []string) { + clusters, err := k.fetchAllClusters() + if err != nil { + k.l.Error("Error in fetching the clusters", "Error", err) + os.Exit(1) + } + + if len(clusters) == 0 { + k.l.Error("No clusters found to connect") + os.Exit(1) + } + + selectDisplay := make(map[string]string, len(clusters)) + valueMaping := make(map[string]controller.Metadata, len(clusters)) + + for idx, cluster := range clusters { + selectDisplay[makeHumanReadableList(cluster)] = strconv.Itoa(idx) + valueMaping[strconv.Itoa(idx)] = controller.Metadata{ + ClusterName: cluster.Name, + ClusterType: cluster.ClusterType, + Provider: cluster.CloudProvider, + Region: cluster.Region, + StateLocation: k.KsctlConfig.PreferedStateStore, + K8sDistro: cluster.K8sDistro, + K8sVersion: cluster.K8sVersion, + } + } + + selectedCluster, err := k.menuDriven.DropDown( + "Select the cluster to for summary", + selectDisplay, + ) + if err != nil { + k.l.Error("Failed to get userinput", "Reason", err) + os.Exit(1) + } + + m := valueMaping[selectedCluster] + + if err := k.telemetry.Send(k.Ctx, k.l, telemetry.EventClusterConnect, telemetry.TelemetryMeta{ + CloudProvider: m.Provider, + StorageDriver: m.StateLocation, + Region: m.Region, + ClusterType: m.ClusterType, + BootstrapProvider: m.K8sDistro, + K8sVersion: m.K8sVersion, + Addons: telemetry.TranslateMetadata(m.Addons), + }); err != nil { + k.l.Debug(k.Ctx, "Failed to send the telemetry", "Reason", err) + } + + if k.loadCloudProviderCreds(m.Provider) != nil { + os.Exit(1) + } + + c, err := common.NewController( + k.Ctx, + k.l, + &controller.Client{ + Metadata: m, + }, + ) + if err != nil { + k.l.Error("Failed to create the controller", "Reason", err) + os.Exit(1) + } + + health, err := c.ClusterSummary() + if err != nil { + k.l.Error("Failed to connect to the cluster", "Reason", err) + os.Exit(1) + } + printClusterSummary(health) + }, + } + + return cmd +} + +func printClusterSummary(summary *common.SummaryOutput) { + cli.NewSummaryUI(os.Stdout).RenderClusterSummary(summary) +} diff --git a/go.mod b/go.mod index 29ca33c..5b1b843 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/creack/pty v1.1.24 github.com/fatih/color v1.18.0 - github.com/ksctl/ksctl/v2 v2.5.0 + github.com/ksctl/ksctl/v2 v2.6.0 github.com/pterm/pterm v0.12.80 github.com/rodaine/table v1.3.0 github.com/spf13/cobra v1.9.1 diff --git a/go.sum b/go.sum index 7280f9d..ee6217e 100644 --- a/go.sum +++ b/go.sum @@ -366,8 +366,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/ksctl/ksctl/v2 v2.5.0 h1:wSjZjcWUmwsAfOCfyYxnygGrp3yHA4xHJSYmGxlbkpc= -github.com/ksctl/ksctl/v2 v2.5.0/go.mod h1:0+AcbNu7OpWLX3a6GU9ZhkZSG8oJDfOrnoJ9KUXAUdE= +github.com/ksctl/ksctl/v2 v2.6.0 h1:Rt74UievbnEECZBzy92OsECqafgXihMyGIlKu2aozS4= +github.com/ksctl/ksctl/v2 v2.6.0/go.mod h1:0+AcbNu7OpWLX3a6GU9ZhkZSG8oJDfOrnoJ9KUXAUdE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= diff --git a/main.go b/main.go index a4ad14b..31e7fb6 100644 --- a/main.go +++ b/main.go @@ -16,7 +16,6 @@ package main import ( "os" - "time" "github.com/ksctl/cli/v2/cmd" ) @@ -28,11 +27,6 @@ func main() { os.Exit(1) } - timer := time.Now() - defer func() { - c.CliLog.Print(c.Ctx, "Time Took", "time", time.Since(timer).String()) - }() - err = c.Execute() if err != nil { c.CliLog.Error("command execution failed", "Reason", err) diff --git a/pkg/cli/summary_ui.go b/pkg/cli/summary_ui.go new file mode 100644 index 0000000..f7dbaba --- /dev/null +++ b/pkg/cli/summary_ui.go @@ -0,0 +1,342 @@ +// Copyright 2025 Ksctl 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 cli + +import ( + "fmt" + "io" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/fatih/color" + "github.com/ksctl/ksctl/v2/pkg/handler/cluster/common" +) + +// SummaryUI is responsible for rendering cluster summary with enhanced UI +type SummaryUI struct { + writer io.Writer +} + +// NewSummaryUI creates a new instance of SummaryUI +func NewSummaryUI(w io.Writer) *SummaryUI { + return &SummaryUI{ + writer: w, + } +} + +// RenderClusterSummary renders the cluster summary with enhanced UI +func (ui *SummaryUI) RenderClusterSummary(summary *common.SummaryOutput) { + parentBox := lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("8")). + Padding(1, 2). + Width(90). + Align(lipgloss.Center) + + banner := lipgloss.NewStyle(). + Padding(0, 1). + Width(80). + Align(lipgloss.Center) + + sectionTitle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("13")). + Bold(true). + MarginTop(1). + Padding(0, 1) + + infoBlock := lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("10")). + Padding(1, 2). + MarginTop(1). + Width(80) + + keyValueRow := func(key, value string) string { + return lipgloss.JoinHorizontal(lipgloss.Top, + lipgloss.NewStyle().Foreground(lipgloss.Color("14")).PaddingRight(3).Width(28).Align(lipgloss.Left).Render(key), + lipgloss.NewStyle().Width(50).Render(value), + ) + } + + var parentBoxContent strings.Builder + + bannerContent := fmt.Sprintf("✨ %s ✨\n\n%s", + lipgloss.NewStyle().Bold(true).Align(lipgloss.Center).Foreground(lipgloss.Color("#FFFFFF")).Render("Cluster Summary"), + lipgloss.NewStyle().Italic(true).Align(lipgloss.Center).Foreground(lipgloss.Color("#DDDDDD")).Render("Health and status of your Kubernetes cluster")) + + parentBoxContent.WriteString(banner.Render(bannerContent)) + parentBoxContent.WriteString("\n\n") + + // Cluster basics section + { + var content strings.Builder + + if summary.ClusterName != "" { + content.WriteString(keyValueRow("Name", summary.ClusterName)) + content.WriteString("\n") + } + if summary.CloudProvider != "" { + content.WriteString(keyValueRow("Cloud", summary.CloudProvider)) + content.WriteString("\n") + } + if summary.ClusterType != "" { + content.WriteString(keyValueRow("Type", summary.ClusterType)) + content.WriteString("\n") + } + if summary.RoundTripLatency != "" { + content.WriteString(keyValueRow("Round Trip Latency", summary.RoundTripLatency)) + content.WriteString("\n") + } + if summary.KubernetesVersion != "" { + content.WriteString(keyValueRow("Kubernetes Version", summary.KubernetesVersion)) + content.WriteString("\n") + } + + if summary.APIServerHealthCheck != nil { + if !summary.APIServerHealthCheck.Healthy { + content.WriteString(keyValueRow("API Server Health", color.HiRedString("Unhealthy"))) + } else { + content.WriteString(keyValueRow("API Server Health", color.HiGreenString("Healthy"))) + } + content.WriteString("\n") + if len(summary.APIServerHealthCheck.FailedComponents) > 0 { + v := color.HiRedString(strings.Join(summary.APIServerHealthCheck.FailedComponents, ", ")) + content.WriteString(keyValueRow("Components Unhealthy", v)) + content.WriteString("\n") + } + } + for k, v := range summary.ControlPlaneComponentVers { + content.WriteString(keyValueRow(k, v)) + content.WriteString("\n") + } + + contentStr := strings.TrimSuffix(content.String(), "\n") + contentBlock := infoBlock.Render(contentStr) + titleBlock := sectionTitle.Render("🔑 Key Attributes") + fullSection := lipgloss.JoinVertical(lipgloss.Left, titleBlock, contentBlock) + + parentBoxContent.WriteString(fullSection) + parentBoxContent.WriteString("\n") + } + yesNoColor := func(v bool, inverse bool) (string, func(format string, a ...interface{}) string) { + if v { + if inverse { + return "Yes", color.HiRedString + } + return "Yes", color.HiGreenString + } + if inverse { + return "No", color.HiGreenString + } + return "No", color.HiRedString + } + applyYesNoColor := func(msg string, formatter func(format string, a ...interface{}) string) string { + return formatter(msg) + } + + { + // Nodes section + var content strings.Builder + for _, node := range summary.Nodes { + content.WriteString("\n") + content.WriteString(color.HiMagentaString(node.Name)) + content.WriteString("\n") + content.WriteString(keyValueRow(" Ready", applyYesNoColor(yesNoColor(node.Ready, false)))) + content.WriteString("\n") + content.WriteString(keyValueRow(" Kubelet Healthy", applyYesNoColor(yesNoColor(node.KubeletHealthy, false)))) + content.WriteString("\n") + content.WriteString(keyValueRow(" Memory Pressure", applyYesNoColor(yesNoColor(node.MemoryPressure, true)))) + content.WriteString("\n") + content.WriteString(keyValueRow(" Disk Pressure", applyYesNoColor(yesNoColor(node.DiskPressure, true)))) + content.WriteString("\n") + content.WriteString(keyValueRow(" Unreachable", applyYesNoColor(yesNoColor(node.NetworkUnavailable, true)))) + content.WriteString("\n") + + content.WriteString(keyValueRow(" Kubelet Version", node.KubeletVersion)) + content.WriteString("\n") + + content.WriteString(keyValueRow(" CRI", node.ContainerRuntimeVersion)) + content.WriteString("\n") + + cpuUtlization := "😢 Currently Unavailable" + if node.CPUUtilization > 0 { + cpuUtlization = fmt.Sprintf("%.2f%% (Total: %s)", node.CPUUtilization, node.CPUUnits) + } + memoryUtlization := "😢 Currently Unavailable" + if node.MemoryUtilization > 0 { + memoryUtlization = fmt.Sprintf("%.2f%% (Total: %s)", node.MemoryUtilization, node.MemUnits) + } + content.WriteString(keyValueRow(" CPU Usage", cpuUtlization)) + content.WriteString("\n") + content.WriteString(keyValueRow(" Memory Usage", memoryUtlization)) + content.WriteString("\n") + } + + contentStr := strings.TrimSuffix(content.String(), "\n") + contentBlock := infoBlock.Render(contentStr) + titleBlock := sectionTitle.Render("🤖 Nodes") + fullSection := lipgloss.JoinVertical(lipgloss.Left, titleBlock, contentBlock) + + parentBoxContent.WriteString(fullSection) + parentBoxContent.WriteString("\n") + } + { + // Workload section + var content strings.Builder + workloads := summary.WorkloadSummary + content.WriteString(keyValueRow("Deployments", fmt.Sprintf("%d", workloads.Deployments))) + content.WriteString("\n") + content.WriteString(keyValueRow("StatefulSets", fmt.Sprintf("%d", workloads.StatefulSets))) + content.WriteString("\n") + content.WriteString(keyValueRow("DaemonSets", fmt.Sprintf("%d", workloads.DaemonSets))) + content.WriteString("\n") + content.WriteString(keyValueRow("CronJobs", fmt.Sprintf("%d", workloads.CronJobs))) + content.WriteString("\n") + content.WriteString(keyValueRow("Namespaces", fmt.Sprintf("%d", workloads.Namespaces))) + content.WriteString("\n") + content.WriteString(keyValueRow("Persistent Volumes", fmt.Sprintf("%d", workloads.PV))) + content.WriteString("\n") + content.WriteString(keyValueRow("Persistent Volume Claims", fmt.Sprintf("%d", workloads.PVC))) + content.WriteString("\n") + content.WriteString(keyValueRow("Storage Class", fmt.Sprintf("%d", workloads.SC))) + content.WriteString("\n") + content.WriteString(keyValueRow("Service (ClusterIP)", fmt.Sprintf("%d", workloads.ClusterIPSVC))) + content.WriteString("\n") + content.WriteString(keyValueRow("Service (LoadBalancer)", fmt.Sprintf("%d", workloads.LoadbalancerSVC))) + content.WriteString("\n") + content.WriteString(keyValueRow("Pods (Running)", fmt.Sprintf("%d", workloads.RunningPods))) + content.WriteString("\n") + + if len(workloads.UnHealthyPods) > 0 { + content.WriteString(color.HiMagentaString("Unhealthy Pods")) + content.WriteString("\n") + } + + for _, pod := range workloads.UnHealthyPods { + content.WriteString("\n") + content.WriteString(" " + color.HiRedString(pod.Name+"@"+pod.Namespace)) + content.WriteString("\n") + objectRef := []string{} + for _, ref := range pod.OwnerRef { + v := fmt.Sprintf("%s/%s@%s", ref.Kind, ref.Name, pod.Namespace) + objectRef = append(objectRef, v) + } + if len(objectRef) == 0 { + objectRef = append(objectRef, "No Owner Reference") + } + content.WriteString(keyValueRow(" "+"Owner Ref", strings.Join(objectRef, ", "))) + content.WriteString("\n") + content.WriteString(keyValueRow(" "+"Failed", applyYesNoColor(yesNoColor(pod.IsFailed, true)))) + content.WriteString("\n") + content.WriteString(keyValueRow(" "+"Pending", applyYesNoColor(yesNoColor(pod.IsPending, true)))) + content.WriteString("\n") + for _, container := range pod.FailedContainers { + content.WriteString("\n") + content.WriteString(keyValueRow(" "+"Container", container.Name)) + content.WriteString("\n") + content.WriteString(keyValueRow(" "+"Restart Count", fmt.Sprintf("%d", container.RestartCount))) + content.WriteString("\n") + content.WriteString(keyValueRow(" "+"Reason", container.WaitingProblem.Reason)) + content.WriteString("\n") + content.WriteString(keyValueRow(" "+"Message", container.WaitingProblem.Message)) + content.WriteString("\n") + } + content.WriteString("\n") + } + + contentStr := strings.TrimSuffix(content.String(), "\n") + contentBlock := infoBlock.Render(contentStr) + titleBlock := sectionTitle.Render("📦 Workloads") + fullSection := lipgloss.JoinVertical(lipgloss.Left, titleBlock, contentBlock) + + parentBoxContent.WriteString(fullSection) + parentBoxContent.WriteString("\n") + } + { + // Recent events section + var content strings.Builder + if len(summary.RecentWarningEvents) == 0 { + content.WriteString(color.HiGreenString("No recent events")) + content.WriteString("\n") + } else { + content.WriteString(color.HiMagentaString("Recent Warning Events (Past 24h)")) + content.WriteString("\n") + for _, event := range summary.RecentWarningEvents { + content.WriteString("\n") + content.WriteString(color.HiMagentaString(fmt.Sprintf("%s/%s@%s", event.Kind, event.Name, event.Namespace))) + content.WriteString("\n") + content.WriteString(keyValueRow(" Reason", event.Reason)) + content.WriteString("\n") + content.WriteString(keyValueRow(" Message", event.Message)) + content.WriteString("\n") + content.WriteString(keyValueRow(" Happened On", event.Time.String())) + content.WriteString("\n") + content.WriteString(keyValueRow(" Reported By", event.ReportedBy)) + content.WriteString("\n") + } + } + + contentStr := strings.TrimSuffix(content.String(), "\n") + contentBlock := infoBlock.Render(contentStr) + titleBlock := sectionTitle.Render("📢 Events") + fullSection := lipgloss.JoinVertical(lipgloss.Left, titleBlock, contentBlock) + + parentBoxContent.WriteString(fullSection) + parentBoxContent.WriteString("\n") + } + { + // Issues section + var content strings.Builder + if len(summary.DetectedIssues) == 0 { + content.WriteString(color.HiGreenString("No issues detected")) + content.WriteString("\n") + } else { + content.WriteString(color.HiMagentaString("Detected Issues")) + content.WriteString("\n") + severityColor := func(severity string) string { + switch severity { + case "Critical": + return color.New(color.BgRed, color.FgBlack).Add(color.Bold).Sprintf("%s", severity) + case "Error": + return color.HiRedString(severity) + case "Warning": + return color.HiYellowString(severity) + default: + return severity + } + } + for _, issue := range summary.DetectedIssues { + content.WriteString(color.HiMagentaString(issue.Component)) + content.WriteString("\n") + content.WriteString(keyValueRow(" Severity", severityColor(issue.Severity))) + content.WriteString("\n") + content.WriteString(keyValueRow(" Message", issue.Message)) + content.WriteString("\n") + content.WriteString(keyValueRow(" Recommendation", issue.Recommendation)) + content.WriteString("\n") + } + } + + contentStr := strings.TrimSuffix(content.String(), "\n") + contentBlock := infoBlock.Render(contentStr) + titleBlock := sectionTitle.Render("🚩 Problems") + fullSection := lipgloss.JoinVertical(lipgloss.Left, titleBlock, contentBlock) + + parentBoxContent.WriteString(fullSection) + parentBoxContent.WriteString("\n") + } + + fmt.Fprintln(ui.writer, parentBox.Render(parentBoxContent.String())) +} diff --git a/pkg/logger/general_logging.go b/pkg/logger/general_logging.go index ba2e8d4..5cf9e88 100644 --- a/pkg/logger/general_logging.go +++ b/pkg/logger/general_logging.go @@ -18,6 +18,7 @@ import ( "context" "fmt" "io" + "os" "reflect" "strings" "sync" @@ -26,6 +27,7 @@ import ( "github.com/fatih/color" "github.com/ksctl/ksctl/v2/pkg/logger" "github.com/rodaine/table" + "golang.org/x/term" "time" ) @@ -34,14 +36,36 @@ type GeneralLog struct { mu *sync.Mutex writter io.Writer level uint + started time.Time } -func (l *GeneralLog) ExternalLogHandler(ctx context.Context, msgType logger.CustomExternalLogLevel, message string) { - l.log(false, msgType, message) +var ( + warnLvl = logger.CustomExternalLogLevel(color.New(color.FgBlack, color.BgYellow).Sprintf("[W]")) + infoLvl = logger.CustomExternalLogLevel(color.New(color.FgBlack, color.BgBlue).Sprintf("[I]")) + noteLvl = logger.CustomExternalLogLevel(color.New(color.FgBlack, color.BgCyan).Sprintf("[N]")) + dbgLvl = logger.CustomExternalLogLevel(color.New(color.FgBlack, color.BgMagenta).Sprintf("[D]")) + passLvl = logger.CustomExternalLogLevel(color.New(color.FgBlack, color.BgGreen).Sprintf("[S]")) + errLvl = logger.CustomExternalLogLevel(color.New(color.FgBlack, color.BgRed).Sprintf("[E]")) +) + +func NewLogger(verbose int, out io.Writer) *GeneralLog { + + var ve uint + + if verbose < 0 { + ve = 9 + } + + return &GeneralLog{ + writter: out, + level: ve, + mu: new(sync.Mutex), + started: time.Now().UTC(), + } } -func (l *GeneralLog) ExternalLogHandlerf(ctx context.Context, msgType logger.CustomExternalLogLevel, format string, args ...interface{}) { - l.log(false, msgType, format, args...) +func (l *GeneralLog) getTime() string { + return color.HiBlackString(fmt.Sprintf("(%s)", time.Since(l.started).Round(time.Second).String())) } func formGroups(v ...any) (format string, vals []any) { @@ -77,7 +101,7 @@ func formGroups(v ...any) (format string, vals []any) { } func isLogEnabled(level uint, msgType logger.CustomExternalLogLevel) bool { - if msgType == logger.LogDebug { + if msgType == dbgLvl { return level >= 9 } return true @@ -87,7 +111,7 @@ func (l *GeneralLog) logErrorf(disablePrefix bool, msg string, args ...any) erro l.mu.Lock() defer l.mu.Unlock() if !disablePrefix { - prefix := fmt.Sprintf("%s%s ", getTime(), logger.LogError) + prefix := fmt.Sprintf("%s%s ", l.getTime(), errLvl) msg = prefix + msg } format, _args := formGroups(args...) @@ -108,85 +132,121 @@ func (l *GeneralLog) log(useGroupFormer bool, msgType logger.CustomExternalLogLe } l.mu.Lock() defer l.mu.Unlock() - prefix := fmt.Sprintf("%s%s ", getTime(), msgType) - if useGroupFormer { + prefix := fmt.Sprintf("%s ", msgType) + elapsedTime := color.HiBlackString(fmt.Sprintf("(%s)", time.Since(l.started).Round(time.Second).String())) + + // Get terminal width for right-aligned time + width, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil || width <= 0 { + // Fallback to a reasonable default width if we can't determine terminal size + width = 120 + } + if useGroupFormer { msgColored := "" switch msgType { - case logger.LogSuccess: + case passLvl: msgColored = color.HiGreenString(msg) - case logger.LogWarning: + case warnLvl: msgColored = color.HiYellowString(msg) - case logger.LogDebug: + case dbgLvl: msgColored = color.HiMagentaString(msg) - case logger.LogNote: + case noteLvl: msgColored = color.HiCyanString(msg) - case logger.LogInfo: + case infoLvl: msgColored = color.HiBlueString(msg) - case logger.LogError: + case errLvl: msgColored = color.HiRedString(msg) } - msg = prefix + msgColored + + // Create the base message with prefix and colored text + baseMsg := prefix + msgColored + format, _args := formGroups(args...) + if _args == nil { - if msgType == logger.LogError { + if msgType == errLvl { l.boxBox( "🛑 We Have Problem", msgColored+" "+format, "Red") return } - fmt.Fprint(l.writter, msg+" "+format) + + // Format the message with right-aligned elapsed time + formattedMessage := formatWithRightAlignedTime(baseMsg+" "+format, elapsedTime, width) + fmt.Fprint(l.writter, formattedMessage) } else { - if msgType == logger.LogError { + if msgType == errLvl { l.boxBox( "🛑 We Have Problem", fmt.Sprintf(msgColored+" "+format, _args...), "Red") return } - fmt.Fprintf(l.writter, msg+" "+format, _args...) + + // Format the message with args and right-aligned elapsed time + fullMsg := fmt.Sprintf(baseMsg+" "+format, _args...) + formattedMessage := formatWithRightAlignedTime(fullMsg, elapsedTime, width) + fmt.Fprint(l.writter, formattedMessage) } } else { - fmt.Fprintf(l.writter, prefix+msg+"\n", args...) + // Format non-group messages with right-aligned time + fullMsg := fmt.Sprintf(prefix+msg+"\n", args...) + formattedMessage := formatWithRightAlignedTime(fullMsg, elapsedTime, width) + fmt.Fprint(l.writter, formattedMessage) } } -func getTime() string { - t := time.Now() - return color.HiBlackString(fmt.Sprintf("%02d:%02d:%02d ", t.Hour(), t.Minute(), t.Second())) -} - -func NewLogger(verbose int, out io.Writer) *GeneralLog { - - var ve uint - - if verbose < 0 { - ve = 9 +func (l *GeneralLog) ExternalLogHandler(ctx context.Context, msgType logger.CustomExternalLogLevel, message string) { + if msgType == logger.LogDebug { + msgType = dbgLvl + } else if msgType == logger.LogError { + msgType = errLvl + } else if msgType == logger.LogInfo { + msgType = infoLvl + } else if msgType == logger.LogWarning { + msgType = warnLvl + } else if msgType == logger.LogSuccess { + msgType = passLvl + } else if msgType == logger.LogNote { + msgType = noteLvl } + l.log(false, msgType, message) +} - return &GeneralLog{ - writter: out, - level: ve, - mu: new(sync.Mutex), +func (l *GeneralLog) ExternalLogHandlerf(ctx context.Context, msgType logger.CustomExternalLogLevel, format string, args ...interface{}) { + if msgType == logger.LogDebug { + msgType = dbgLvl + } else if msgType == logger.LogError { + msgType = errLvl + } else if msgType == logger.LogInfo { + msgType = infoLvl + } else if msgType == logger.LogWarning { + msgType = warnLvl + } else if msgType == logger.LogSuccess { + msgType = passLvl + } else if msgType == logger.LogNote { + msgType = noteLvl } + l.log(false, msgType, format, args...) } func (l *GeneralLog) Print(ctx context.Context, msg string, args ...any) { - l.log(true, logger.LogInfo, msg, args...) + l.log(true, infoLvl, msg, args...) } func (l *GeneralLog) Success(ctx context.Context, msg string, args ...any) { - l.log(true, logger.LogSuccess, msg, args...) + l.log(true, passLvl, msg, args...) } func (l *GeneralLog) Note(ctx context.Context, msg string, args ...any) { - l.log(true, logger.LogNote, msg, args...) + l.log(true, noteLvl, msg, args...) } func (l *GeneralLog) Debug(ctx context.Context, msg string, args ...any) { - l.log(true, logger.LogDebug, msg, args...) + l.log(true, dbgLvl, msg, args...) } func (l *GeneralLog) Error(msg string, args ...any) { - l.log(true, logger.LogError, msg, args...) + l.log(true, errLvl, msg, args...) } func (l *GeneralLog) NewError(ctx context.Context, msg string, args ...any) error { @@ -194,7 +254,7 @@ func (l *GeneralLog) NewError(ctx context.Context, msg string, args ...any) erro } func (l *GeneralLog) Warn(ctx context.Context, msg string, args ...any) { - l.log(true, logger.LogWarning, msg, args...) + l.log(true, warnLvl, msg, args...) } func (l *GeneralLog) Table(ctx context.Context, headers []string, data [][]string) { @@ -276,3 +336,69 @@ func (l *GeneralLog) Box(ctx context.Context, title string, lines string) { l.boxBox(title, lines, "Green") } + +// formatWithRightAlignedTime formats a log message with the elapsed time right-aligned +func formatWithRightAlignedTime(message string, elapsedTime string, width int) string { + // Strip ANSI color codes for length calculation + plainMessage := stripANSIColors(message) + plainTime := stripANSIColors(elapsedTime) + + // Check if message has newline + endsWithNewline := strings.HasSuffix(plainMessage, "\n") + + // Remove trailing newline for calculations + if endsWithNewline { + plainMessage = plainMessage[:len(plainMessage)-1] + } + + // Get the actual message without newline for length calculation + messageWithoutNewline := message + if endsWithNewline && len(message) > 0 { + messageWithoutNewline = message[:len(message)-1] + } + + // Calculate available space and padding needed + msgLen := len(plainMessage) + timeLen := len(plainTime) + + // Ensure we have enough space, accounting for at least 2 spaces between message and time + padding := max(width-msgLen-timeLen, 2) + + // Build the formatted line + var result strings.Builder + result.WriteString(messageWithoutNewline) + result.WriteString(strings.Repeat(" ", padding)) + result.WriteString(elapsedTime) + if endsWithNewline { + result.WriteString("\n") + } + + return result.String() +} + +// stripANSIColors removes ANSI color codes from a string to get its visual length +func stripANSIColors(s string) string { + // ANSI escape code regex: \x1b\[[0-9;]*m + var result strings.Builder + inEscapeSeq := false + + for _, r := range s { + if inEscapeSeq { + if r == 'm' { + inEscapeSeq = false + } + continue + } + + if r == '\x1b' { + inEscapeSeq = true + continue + } + + if !inEscapeSeq { + result.WriteRune(r) + } + } + + return result.String() +}