1
0
Fork 0
mirror of https://github.com/postmannen/ctrl.git synced 2025-04-23 20:48:38 +00:00
ctrl/tui/main.go

398 lines
11 KiB
Go
Raw Normal View History

package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
tcell "github.com/gdamore/tcell/v2"
"github.com/nats-io/nats.go"
"github.com/rivo/tview"
)
type CommandData struct {
ToNodes []string `json:"toNodes"`
ToNode string `json:"toNode"`
JetstreamToNode string `json:"jetstreamToNode,omitempty"`
UseDetectedShell bool `json:"useDetectedShell"`
Method string `json:"method"`
MethodArgs []string `json:"methodArgs"`
MethodTimeout int `json:"methodTimeout"`
FromNode string `json:"fromNode"`
ReplyMethod string `json:"replyMethod"`
ACKTimeout int `json:"ACKTimeout"`
}
// Create a global variable for the output view so it can be accessed from NATS handlers
var outputView *tview.TextView
var historyList *tview.List
var filesList *tview.List
var app *tview.Application
var nc *nats.Conn
// Store form values globally
var (
toNodes string = "btdev1"
methodArgs = []string{"/bin/bash", "-c", "ls -l"}
// Store history commands
historyCommands []string
)
func createCommandPage() tview.Primitive {
// Create a flex container for the entire page
mainFlex := tview.NewFlex().SetDirection(tview.FlexRow)
// Create form for inputs
formFields := tview.NewForm()
formFields.SetBorder(true).SetTitle("Command Form")
// Create a List for command history
historyList = tview.NewList()
historyList.SetBorder(true)
historyList.SetTitle("History")
// Handle selection from history
historyList.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) {
// Update Method Arg 3 field with selected command
methodArg3Field := formFields.GetFormItemByLabel("Method Arg 3").(*tview.InputField)
methodArg3Field.SetText(mainText)
methodArgs[2] = mainText // Update global variable
app.SetFocus(formFields) // Return focus to form
})
// Create a List for files
filesList = tview.NewList()
filesList.SetBorder(true)
filesList.SetTitle("Files")
filesList.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) {
// When a file is selected, update Method Arg 3 with the CTRL_FILE template
methodArg3Field := formFields.GetFormItemByLabel("Method Arg 3").(*tview.InputField)
methodArg3Field.SetText("{{CTRL_FILE:" + secondaryText + "}}")
methodArgs[2] = methodArg3Field.GetText()
app.SetFocus(formFields)
})
// Create a vertical flex for history and files lists
rightPanelFlex := tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(historyList, 0, 1, false).
AddItem(filesList, 0, 1, false)
// Create a horizontal flex for the form and right panel
topFlex := tview.NewFlex().
AddItem(formFields, 0, 2, true).
AddItem(rightPanelFlex, 0, 1, false)
// Create output text view
outputView = tview.NewTextView().
SetDynamicColors(true).
SetChangedFunc(func() {
app.Draw()
})
outputView.SetBorder(true).SetTitle("Output")
// Add form fields
formFields.AddInputField("To Nodes", toNodes, 30, nil, func(text string) {
})
formFields.AddInputField("Jetstream To Node", "", 30, nil, func(text string) {
// Value will be read directly from form when needed
})
formFields.AddCheckbox("Use Detected Shell", false, func(checked bool) {
// Value will be read directly from form when needed
})
formFields.AddInputField("Method", "cliCommand", 30, nil, func(text string) {
// Value will be read directly from form when needed
})
formFields.AddInputField("Method Arg 1", methodArgs[0], 30, nil, func(text string) {
methodArgs[0] = text
})
formFields.AddInputField("Method Arg 2", methodArgs[1], 30, nil, func(text string) {
methodArgs[1] = text
})
formFields.AddInputField("Method Arg 3", methodArgs[2], 30, nil, func(text string) {
methodArgs[2] = text
}).SetFieldStyle(tcell.StyleDefault.Blink(true))
formFields.AddInputField("Method Timeout", "3", 30, nil, func(text string) {
// Value will be read directly from form when needed
})
formFields.AddInputField("Reply Method", "webUI", 30, nil, func(text string) {
// Value will be read directly from form when needed
})
formFields.AddInputField("ACK Timeout", "0", 30, nil, func(text string) {
// Value will be read directly from form when needed
})
// Subscribe to responses
fromNode := "btdev1"
// Add buttons
formFields.AddButton("Send", func() {
// Get and save the current command to history
currentCmd := formFields.GetFormItemByLabel("Method Arg 3").(*tview.InputField).GetText()
if currentCmd != "" {
// Add to history if not already present
found := false
for _, cmd := range historyCommands {
if cmd == currentCmd {
found = true
break
}
}
if !found {
historyCommands = append([]string{currentCmd}, historyCommands...)
historyList.Clear()
for _, cmd := range historyCommands {
historyList.AddItem(cmd, "", 0, nil).ShowSecondaryText(false)
}
}
}
// Get current values from form
methodTimeout, _ := strconv.Atoi(formFields.GetFormItemByLabel("Method Timeout").(*tview.InputField).GetText())
ackTimeout, _ := strconv.Atoi(formFields.GetFormItemByLabel("ACK Timeout").(*tview.InputField).GetText())
// Get and clean toNodes
toNodesText := formFields.GetFormItemByLabel("To Nodes").(*tview.InputField).GetText()
toNodes := strings.Split(toNodesText, ",")
// Trim spaces and filter out empty nodes
// cleanedToNodes := []string{}
// for _, node := range toNodes {
// if trimmed := strings.TrimSpace(node); trimmed != "" {
// cleanedToNodes = append(cleanedToNodes, trimmed)
// }
// }
cmd := CommandData{
// ToNodes: The values of toNodes will be used to fill toNode field when sending below.
JetstreamToNode: formFields.GetFormItemByLabel("Jetstream To Node").(*tview.InputField).GetText(),
UseDetectedShell: formFields.GetFormItemByLabel("Use Detected Shell").(*tview.Checkbox).IsChecked(),
Method: formFields.GetFormItemByLabel("Method").(*tview.InputField).GetText(),
MethodArgs: methodArgs,
MethodTimeout: methodTimeout,
FromNode: fromNode,
ReplyMethod: formFields.GetFormItemByLabel("Reply Method").(*tview.InputField).GetText(),
ACKTimeout: ackTimeout,
}
// Send command to each node
for _, node := range toNodes {
cmd.ToNode = node
// ---------------------------
var filePathToOpen string
foundFile := false
if strings.Contains(cmd.MethodArgs[2], "{{CTRL_FILE:") {
foundFile = true
// Example to split:
// echo {{CTRL_FILE:/somedir/msg_file.yaml}}>ctrlfile.txt
//
// Split at colon. We want the part after.
ss := strings.Split(cmd.MethodArgs[2], ":")
// Split at "}}",so pos [0] in the result contains just the file path.
sss := strings.Split(ss[1], "}}")
filePathToOpen = sss[0]
}
if foundFile {
fh, err := os.Open(filePathToOpen)
if err != nil {
fmt.Fprintf(outputView, "Error opening file: %v\n", err)
return
}
defer fh.Close()
b, err := io.ReadAll(fh)
if err != nil {
fmt.Fprintf(outputView, "Error reading file: %v\n", err)
return
}
// Replace the {{CTRL_FILE}} with the actual content read from file.
re := regexp.MustCompile(`(.*)({{CTRL_FILE.*}})(.*)`)
cmd.MethodArgs[2] = re.ReplaceAllString(cmd.MethodArgs[2], `${1}`+string(b)+`${3}`)
fmt.Fprintf(outputView, "DEBUG: Replaced {{CTRL_FILE}} with file content: %s\n", cmd.MethodArgs[2])
// ---
}
// ---------------------------
jsonData, err := json.Marshal(cmd)
if err != nil {
fmt.Fprintf(outputView, "Error creating command: %v\n", err)
return
}
subject := fmt.Sprintf("%s.%s", strings.TrimSpace(node), cmd.Method)
if err := nc.Publish(subject, jsonData); err != nil {
fmt.Fprintf(outputView, "Error sending to %s: %v\n", node, err)
} else {
// fmt.Fprintf(outputView, "*** Command %s sent to %s, subject: %s\n", jsonData, node, subject)
}
}
})
// Layout setup - adjust the proportion between form and output
mainFlex.AddItem(topFlex, 0, 7, true) // Form and history section
mainFlex.AddItem(outputView, 0, 5, false) // Output takes remaining space
return mainFlex
}
func createNodesPage() tview.Primitive {
// Create an empty page for now
return tview.NewBox().SetBorder(true).SetTitle("Nodes")
}
func updateFilesList() {
// Clear current list
// filesList.Clear()
// Create tui/files directory if it doesn't exist
filesDir := "files"
// Read all files from the directory
files, err := os.ReadDir(filesDir)
if err != nil {
fmt.Fprintf(outputView, "Error reading files directory: %v\n", err)
return
}
if filesList == nil {
fmt.Printf("FILES FILE FILE: %v\n", files)
os.Exit(1)
}
// Add each file to the list
for _, file := range files {
//fmt.Fprintf(outputView, "DEBUG: File: %s\n", file.Name())
//if !file.IsDir() {
filesList.AddItem(file.Name(), filepath.Join(filesDir, file.Name()), 0, nil)
//}
}
}
func main() {
app = tview.NewApplication()
// Connect to NATS
var err error
nc, err = nats.Connect("nats://localhost:4222")
if err != nil {
panic(err)
}
defer nc.Close()
fromNode := "btdev1"
// --------------------------
// fmt.Fprintf(outputView, "DEBUG: BEFORE SUBSCRIBE\n")
sub, err := nc.Subscribe(fromNode+".webUI", func(msg *nats.Msg) {
var jsonMsg struct {
Data string `json:"data"`
}
if err := json.Unmarshal(msg.Data, &jsonMsg); err != nil {
app.QueueUpdateDraw(func() {
fmt.Fprintf(outputView, "Error decoding message: %v\n", err)
})
return
}
// Decode base64 data
decoded, err := base64.StdEncoding.DecodeString(jsonMsg.Data)
if err != nil {
app.QueueUpdateDraw(func() {
fmt.Fprintf(outputView, "Error decoding base64: %v\n", err)
})
return
}
app.QueueUpdateDraw(func() {
fmt.Fprintf(outputView, "\nReceived message on %s:\n%s\n", msg.Subject, string(decoded))
})
})
// fmt.Fprintf(outputView, "DEBUG: AFTER SUBSCRIBE\n")
if err != nil {
panic(err)
}
defer func() {
sub.Unsubscribe()
}()
// --------------------------
// Create pages to manage multiple screens
pages := tview.NewPages()
// Create menu
menu := tview.NewList().
AddItem("Commands", "Send commands to nodes", 'c', func() {
pages.SwitchToPage("commands")
}).ShowSecondaryText(false).
AddItem("Nodes", "View and manage nodes", 'n', func() {
pages.SwitchToPage("nodes")
}).ShowSecondaryText(false).
AddItem("Quit", "Press to exit", 'q', func() {
app.Stop()
})
menu.SetBorder(true).SetTitle("Menu")
// Create layout with menu on left and pages on right
flex := tview.NewFlex().
AddItem(menu, 20, 1, true).
AddItem(pages, 0, 1, false)
// Add pages
pages.AddPage("commands", createCommandPage(), true, true)
pages.AddPage("nodes", createNodesPage(), true, false)
// Global keyboard shortcuts
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyEsc:
// If on a page, go back to menu
if menu.HasFocus() {
app.Stop()
} else {
app.SetFocus(menu)
}
return nil
case tcell.KeyTab:
// Toggle between menu and current page
if menu.HasFocus() {
app.SetFocus(pages)
} else {
app.SetFocus(menu)
}
return nil
}
return event
})
// Update files list at startup
updateFilesList()
if err := app.SetRoot(flex, true).EnableMouse(true).Run(); err != nil {
panic(err)
}
}