mirror of
https://github.com/postmannen/ctrl.git
synced 2025-04-23 20:48:38 +00:00
398 lines
11 KiB
Go
398 lines
11 KiB
Go
|
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)
|
||
|
}
|
||
|
}
|