diff --git a/pkg/app/execontext.go b/pkg/app/execontext.go index dd726f3f..b390daa9 100644 --- a/pkg/app/execontext.go +++ b/pkg/app/execontext.go @@ -16,8 +16,9 @@ import ( ) const ( - ofJSON = "json" - ofText = "text" + ofJSON = "json" + ofText = "text" + ofSubscription = "subscription" ) type ExecutionContext struct { @@ -122,20 +123,34 @@ func NewExecutionContext( } type Output struct { - CmdName string - Quiet bool - OutputFormat string - DataChannels map[string]chan interface{} + CmdName string + Quiet bool + OutputFormat string + DataChannels map[string]chan interface{} + internalDataCh chan interface{} } func NewOutput(cmdName string, quiet bool, outputFormat string, channels map[string]chan interface{}) *Output { ref := &Output{ - CmdName: cmdName, - Quiet: quiet, - OutputFormat: outputFormat, - DataChannels: channels, + CmdName: cmdName, + Quiet: quiet, + OutputFormat: outputFormat, + DataChannels: channels, + internalDataCh: make(chan interface{}), } + // We want to listen to the internal channel for any data + // And dump it onto the appropriate DataChannels + go func() { + for data := range ref.internalDataCh { + if data != nil { + for _, ch := range ref.DataChannels { + ch <- data + } + } + } + }() + return ref } @@ -272,6 +287,15 @@ func (ref *Output) Message(data string) { } +func (ref *Output) Data(channelKey string, data interface{}) { + if ch, exists := ref.DataChannels[channelKey]; exists { + ch <- data // Send data to the corresponding channel + fmt.Printf("Data sent to channel '%s': %v\n", channelKey, data) + } else { + fmt.Printf("Channel for channelKey '%s' not found\n", channelKey) + } +} + func (ref *Output) State(state string, params ...OutVars) { if ref.Quiet { return @@ -344,7 +368,8 @@ var ( ) func (ref *Output) Info(infoType string, params ...OutVars) { - if ref.Quiet { + // TODO - carry this pattern to other Output methods + if ref.Quiet && ref.OutputFormat != ofSubscription { return } @@ -379,7 +404,8 @@ func (ref *Output) Info(infoType string, params ...OutVars) { fmt.Println(string(jsonData)) case ofText: fmt.Printf("cmd=%s info=%s%s%s\n", ref.CmdName, itcolor(infoType), sep, data) - + case ofSubscription: + ref.internalDataCh <- msg // Send data to the internal channel default: log.Fatalf("Unknown console output flag: %s\n. It should be either 'text' or 'json", ref.OutputFormat) } diff --git a/pkg/app/master/command/cliflags.go b/pkg/app/master/command/cliflags.go index 91e96ea8..212c06b6 100644 --- a/pkg/app/master/command/cliflags.go +++ b/pkg/app/master/command/cliflags.go @@ -312,6 +312,10 @@ const ( FlagContainerDNSSearchUsage = "Add a dns search domain for unqualified hostnames analyzing image at runtime" FlagMountUsage = "Mount volume analyzing image" FlagDeleteFatImageUsage = "Delete generated fat image requires --dockerfile flag" + + // TUI Related flags + FlagTUI = "tui" + FlagTUIUsage = "Enable terminal user interface mode" ) // Container runtime command flag names and usage descriptions @@ -1026,6 +1030,12 @@ var CommonFlags = map[string]cli.Flag{ Usage: FlagRuntimeUsage, EnvVars: []string{"DSLIM_CRT_NAME"}, }, + // TUI Mode + FlagTUI: &cli.BoolFlag{ + Name: FlagTUI, + Usage: FlagTUIUsage, + EnvVars: []string{"DSLIM_TUI"}, + }, } //var CommonFlags diff --git a/pkg/app/master/command/debug/cli.go b/pkg/app/master/command/debug/cli.go index 84e1f9e6..45cbb707 100644 --- a/pkg/app/master/command/debug/cli.go +++ b/pkg/app/master/command/debug/cli.go @@ -8,6 +8,7 @@ import ( "github.com/mintoolkit/mint/pkg/app" "github.com/mintoolkit/mint/pkg/app/master/command" + "github.com/mintoolkit/mint/pkg/app/master/tui" "github.com/mintoolkit/mint/pkg/crt" ) @@ -86,6 +87,8 @@ type CommandParams struct { UseSecurityContextFromTarget bool /// fallback to using target container user if it's non-root (mostly for kubernetes) DoFallbackToTargetUser bool + // `debug --tui` use mode` + TUI bool } func ParseNameValueList(list []string) []NVPair { @@ -166,9 +169,19 @@ var CLI = &cli.Command{ cflag(FlagRunPrivileged), cflag(FlagSecurityContextFromTarget), cflag(FlagFallbackToTargetUser), + command.Cflag(command.FlagTUI), }, Action: func(ctx *cli.Context) error { gcvalues := command.GlobalFlagValues(ctx) + + // If we stick with this approach, the user should be communicated to + // use `--tui` as a standalone flag for `debug` + if ctx.Bool(command.FlagTUI) { + initialTUI := InitialTUI(true, gcvalues) + tui.RunTUI(initialTUI, true) + return nil + } + xc := app.NewExecutionContext( Name, gcvalues.QuietCLIMode, @@ -209,6 +222,7 @@ var CLI = &cli.Command{ DoRunPrivileged: ctx.Bool(FlagRunPrivileged), UseSecurityContextFromTarget: ctx.Bool(FlagSecurityContextFromTarget), DoFallbackToTargetUser: ctx.Bool(FlagFallbackToTargetUser), + TUI: ctx.Bool(command.FlagTUI), } if commandParams.ActionListNamespaces && diff --git a/pkg/app/master/command/debug/handler.go b/pkg/app/master/command/debug/handler.go index 3d5233ac..201b8e7f 100644 --- a/pkg/app/master/command/debug/handler.go +++ b/pkg/app/master/command/debug/handler.go @@ -82,6 +82,7 @@ func OnCommand( resolved := command.ResolveAutoRuntime(commandParams.Runtime) logger.Tracef("runtime.handler: rt=%s resolved=%s", commandParams.Runtime, resolved) + switch resolved { case crt.DockerRuntime: client, err := dockerclient.New(gparams.ClientConfig) diff --git a/pkg/app/master/command/debug/tui.go b/pkg/app/master/command/debug/tui.go new file mode 100644 index 00000000..09dc93d5 --- /dev/null +++ b/pkg/app/master/command/debug/tui.go @@ -0,0 +1,262 @@ +package debug + +import ( + "strconv" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + + "github.com/mintoolkit/mint/pkg/app" + "github.com/mintoolkit/mint/pkg/app/master/command" + "github.com/mintoolkit/mint/pkg/app/master/tui/common" + "github.com/mintoolkit/mint/pkg/app/master/tui/keys" + + tea "github.com/charmbracelet/bubbletea" +) + +// TUI represents the internal state of the terminal user interface. +type TUI struct { + width int + height int + standalone bool + loading bool + table table.Table + + showDebuggableContainers bool + + gcvalues *command.GenericParams +} + +// Styles - move to `common` +const ( + gray = lipgloss.Color("#737373") + lightGray = lipgloss.Color("#d3d3d3") + white = lipgloss.Color("#ffffff") +) + +var ( + // HeaderStyle is the lipgloss style used for the table headers. + HeaderStyle = lipgloss.NewStyle().Foreground(white).Bold(true).Align(lipgloss.Center) + // CellStyle is the base lipgloss style used for the table rows. + CellStyle = lipgloss.NewStyle().Padding(0, 1).Width(14) + // OddRowStyle is the lipgloss style used for odd-numbered table rows. + OddRowStyle = CellStyle.Foreground(gray) + // EvenRowStyle is the lipgloss style used for even-numbered table rows. + EvenRowStyle = CellStyle.Foreground(lightGray) + // BorderStyle is the lipgloss style used for the table border. + BorderStyle = lipgloss.NewStyle().Foreground(white) +) + +// End Styles - move to common - block + +func LoadTUI() *TUI { + m := &TUI{ + width: 20, + height: 15, + loading: true, + } + return m +} + +// InitialTUI returns the initial state of the model. +func InitialTUI(standalone bool, gcvalues *command.GenericParams) *TUI { + m := &TUI{ + standalone: standalone, + width: 20, + height: 15, + gcvalues: gcvalues, + } + + return m +} + +func (m TUI) Init() tea.Cmd { + // Just return `nil`, which means "no I/O right now, please." + return nil +} + +type DebuggableContainer struct { + Name string + Image string +} + +// Update is called to handle user input and update the model's state. +func (m TUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case common.Event: + debuggableContainersCh := make(chan interface{}) + // NOTE -> the names of both the channel map and the channel are misleading + // as more than just the debuggable container information is dumped on it + // at the moment. + debuggableContainersChannelMap := map[string]chan interface{}{ + "debuggableContainers": debuggableContainersCh, + } + // In addition to passing the channel(s) we will use to transport data + // we should pass: + // the outputs we want to subscribe to: State | Info | Error + xc := app.NewExecutionContext( + "tui", + // Quiet -> when set to true, returns on the first line for each + // Execution context method + true, + "subscription", + debuggableContainersChannelMap, + ) + + cparams := &CommandParams{ + // NOTE -> should not always pass docker here. + Runtime: "docker", + // Note -> we should not pass this by default, and instead pass it when a user asks. + ActionListDebuggableContainers: true, + // How to pass the target ref: + // TargetRef: "my-nginx" + } + + gcValue, ok := msg.Data.(*command.GenericParams) + if !ok || gcValue == nil { + return nil, nil + } + + go OnCommand(xc, gcValue, cparams) + + counter := 0 + var counterCeiling int + var debuggableContainers []DebuggableContainer + + doneCh := make(chan struct{}) + go func() { + for debuggableContainersData := range debuggableContainersCh { + channelResponse, ok := debuggableContainersData.(map[string]string) + if !ok || channelResponse == nil { + continue + } + infoValue, infoExists := channelResponse["info"] + if infoExists { + // Set total debuggable container counter ceiling + if infoValue == "debuggable.containers" && counterCeiling == 0 { + countInt, err := strconv.Atoi(channelResponse["count"]) + if err != nil { + continue + } + counterCeiling = countInt + } else if infoValue == "debuggable.container" { + debuggableContainers = append(debuggableContainers, DebuggableContainer{ + Name: channelResponse["name"], + Image: channelResponse["image"], + }) + counter++ + } + } + + if counterCeiling > 0 && counter == counterCeiling { + break + } + } + m.table = generateTable(debuggableContainers) + close(doneCh) + }() + + <-doneCh + m.showDebuggableContainers = !m.showDebuggableContainers + return m, nil + case tea.KeyMsg: + switch { + case key.Matches(msg, keys.Global.Quit): + return m, tea.Quit + // NOTE -> We should only support this back navigation, + // if the tui is not in standalone mode. + case key.Matches(msg, keys.Global.Back): + return common.TUIsInstance.Home, nil + case key.Matches(msg, keys.Debug.LoadDebuggableContainers): + // Kickoff loading of debuggable containers in standalone mode. + if m.standalone { + loadDebuggableContainers := common.Event{ + Type: common.LaunchDebugEvent, + Data: m.gcvalues, + } + m, _ := m.Update(loadDebuggableContainers) + return m, nil + } + + // When used via `tui -> debug` + m.showDebuggableContainers = !m.showDebuggableContainers + return m, nil + + } + } + return m, nil +} + +func generateTable(debuggableContainers []DebuggableContainer) table.Table { + var rows [][]string + for _, container := range debuggableContainers { + rows = append(rows, []string{container.Name, container.Image}) + } + // Note - we will start this as a lipgloss table, but once we add interaction + // it should likely be converted to a bubble tea table. + t := table.New(). + Border(lipgloss.NormalBorder()). + BorderStyle(BorderStyle). + StyleFunc(func(row, col int) lipgloss.Style { + var style lipgloss.Style + + switch { + case row == 0: + return HeaderStyle + case row%2 == 0: + style = EvenRowStyle + default: + style = OddRowStyle + } + + return style + }). + Headers("Name", "Image"). + Rows(rows...) + + return *t +} + +// View returns the view that should be displayed. +func (m TUI) View() string { + var components []string + + // What do you want to do? + // 1. List debuggable containers + // 2. List debug images + // 3. List debug sessions + // 4. Connect to a debug session + // 5. Start a new debug session + + content := "Debug Dashboard\n" + + components = append(components, content) + + if m.showDebuggableContainers { + header := "Debuggable Containers\n" + components = append(components, header, m.table.String()) + } + + components = append(components, m.help()) + + return lipgloss.JoinVertical(lipgloss.Left, + components..., + ) +} + +func (m TUI) help() string { + var listOrHide string + + if m.showDebuggableContainers { + listOrHide = "hide" + } else { + listOrHide = "list" + } + + if m.standalone { + return common.HelpStyle("• l: " + listOrHide + " debuggable containers • q: quit") + } + + return common.HelpStyle("• l: " + listOrHide + " debuggable containers • esc: back • q: quit") +} diff --git a/pkg/app/master/command/images/flags.go b/pkg/app/master/command/images/flags.go index 77c80e86..296e27aa 100644 --- a/pkg/app/master/command/images/flags.go +++ b/pkg/app/master/command/images/flags.go @@ -9,8 +9,9 @@ import ( const ( FlagFilter = "filter" FlagFilterUsage = "container image filter pattern" - FlagTUI = "tui" - FlagTUIUsage = "terminal user interface" + // TODO - replace with reference to `master/command.FlagTUI` + FlagTUI = "tui" + FlagTUIUsage = "terminal user interface" ) var Flags = map[string]cli.Flag{ diff --git a/pkg/app/master/command/images/handler.go b/pkg/app/master/command/images/handler.go index e7ba1394..ab12e7b1 100644 --- a/pkg/app/master/command/images/handler.go +++ b/pkg/app/master/command/images/handler.go @@ -136,8 +136,7 @@ func OnCommand( } else if cparams.GlobalTUI { // `tui` -> `i` // TODO - create a central store for the lookup key. // As this key needs to be the same on the sender and the receiver. - xc.Out.DataChannels["images"] <- images - close(xc.Out.DataChannels["images"]) + xc.Out.Data("images", images) } else if xc.Out.Quiet { if xc.Out.OutputFormat == command.OutputFormatJSON { fmt.Printf("%s\n", jsonutil.ToPretty(images)) diff --git a/pkg/app/master/tui/common/event.go b/pkg/app/master/tui/common/event.go index 4adcb8b9..d2262621 100644 --- a/pkg/app/master/tui/common/event.go +++ b/pkg/app/master/tui/common/event.go @@ -1,7 +1,8 @@ package common const ( - GetImagesEvent EventType = "getImages" + GetImagesEvent EventType = "getImages" + LaunchDebugEvent EventType = "launchDebug" ) type ( diff --git a/pkg/app/master/tui/debug/model.go b/pkg/app/master/tui/debug/model.go deleted file mode 100644 index a01c5425..00000000 --- a/pkg/app/master/tui/debug/model.go +++ /dev/null @@ -1,67 +0,0 @@ -package debug - -import ( - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/lipgloss" - "github.com/mintoolkit/mint/pkg/app/master/tui/common" - "github.com/mintoolkit/mint/pkg/app/master/tui/keys" - - tea "github.com/charmbracelet/bubbletea" -) - -// TUI represents the state of the TUI. -type TUI struct { - standalone bool -} - -// InitialTUI returns the initial state of the model. -func InitialTUI(standalone bool) *TUI { - m := &TUI{ - standalone: standalone, - } - - return m -} - -func (m TUI) Init() tea.Cmd { - // Just return `nil`, which means "no I/O right now, please." - return nil -} - -// Update is called to handle user input and update the model's state. -func (m TUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, keys.Global.Quit): - return m, tea.Quit - // NOTE -> We should only support this back navigation, - // if the images tui is not standalone - case key.Matches(msg, keys.Global.Back): - return common.TUIsInstance.Home, nil - } - } - return m, nil -} - -// View returns the view that should be displayed. -func (m TUI) View() string { - var components []string - - content := "Debug support coming soon" - - components = append(components, content) - - components = append(components, m.help()) - - return lipgloss.JoinVertical(lipgloss.Left, - components..., - ) -} - -func (m TUI) help() string { - if m.standalone { - return common.HelpStyle("• q: quit") - } - return common.HelpStyle("• esc: back • q: quit") -} diff --git a/pkg/app/master/tui/home/model.go b/pkg/app/master/tui/home/model.go index 29c54c61..ee898c07 100644 --- a/pkg/app/master/tui/home/model.go +++ b/pkg/app/master/tui/home/model.go @@ -5,9 +5,9 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/mintoolkit/mint/pkg/app/master/command" + "github.com/mintoolkit/mint/pkg/app/master/command/debug" "github.com/mintoolkit/mint/pkg/app/master/command/images" "github.com/mintoolkit/mint/pkg/app/master/tui/common" - "github.com/mintoolkit/mint/pkg/app/master/tui/debug" "github.com/mintoolkit/mint/pkg/app/master/tui/keys" ) @@ -46,9 +46,14 @@ func (m TUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { common.TUIsInstance.Images = LoadTUI return LoadTUI.Update(getImagesEvent) case key.Matches(msg, keys.Home.Debug): - debugModel := debug.InitialTUI(false) - common.TUIsInstance.Debug = debugModel - return debugModel.Update(nil) + launchDebugEvent := common.Event{ + Type: common.LaunchDebugEvent, + Data: m.Gcvalues, + } + + LoadTUI := debug.LoadTUI() + common.TUIsInstance.Debug = LoadTUI + return LoadTUI.Update(launchDebugEvent) } } return m, nil diff --git a/pkg/app/master/tui/keys/keys.go b/pkg/app/master/tui/keys/keys.go index 1f578859..9bf9fba4 100644 --- a/pkg/app/master/tui/keys/keys.go +++ b/pkg/app/master/tui/keys/keys.go @@ -31,6 +31,10 @@ type home struct { Debug key.Binding } +type debug struct { + LoadDebuggableContainers key.Binding +} + var Home = home{ Images: key.NewBinding( key.WithKeys("i"), @@ -41,3 +45,10 @@ var Home = home{ key.WithHelp("d", "Open debug view"), ), } + +var Debug = debug{ + LoadDebuggableContainers: key.NewBinding( + key.WithKeys("l"), + key.WithHelp("l", "Load debuggable containers"), + ), +}