1
0
Fork 0
mirror of https://github.com/postmannen/ctrl.git synced 2025-04-15 00:36:31 +00:00
ctrl/tui/main.go
postmannen 5d99554c3b Initial Web UI, and TUI implementations :
NB: Breaking change, since the message format have changed.
Uses nats to communicate with ctrl, and nkeys for auth.
Supports the use of files as templates for scripts to run. The main source for the templates are the ./files directory on the node named central.
webUI: Settings are stored locally using Javascript localStorage.
shortcut ctrl+t to open the template menu for webui
2025-02-14 06:43:52 +01:00

397 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)
}
}