|
|
@@ -6,6 +6,8 @@ import (
|
|
|
"fmt"
|
|
|
"net"
|
|
|
"os"
|
|
|
+ "os/exec"
|
|
|
+ "path/filepath"
|
|
|
"regexp"
|
|
|
"runtime"
|
|
|
"sort"
|
|
|
@@ -108,14 +110,66 @@ func formatBytes(bytes int64) string {
|
|
|
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
|
|
}
|
|
|
|
|
|
+// scanCRLF is a split function for a Scanner that returns each line of
|
|
|
+// text, stripped of any trailing end-of-line marker. The returned line may
|
|
|
+// be empty. The end-of-line marker is one optional carriage return followed
|
|
|
+// by one mandatory newline, OR just a carriage return.
|
|
|
+func scanCRLF(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
|
|
+ if atEOF && len(data) == 0 {
|
|
|
+ return 0, nil, nil
|
|
|
+ }
|
|
|
+ if i := bytes.IndexByte(data, '\n'); i >= 0 {
|
|
|
+ // We have a full newline-terminated line.
|
|
|
+ return i + 1, dropCR(data[0:i]), nil
|
|
|
+ }
|
|
|
+ if i := bytes.IndexByte(data, '\r'); i >= 0 {
|
|
|
+ // We have a CR-terminated line.
|
|
|
+ return i + 1, data[0:i], nil
|
|
|
+ }
|
|
|
+ // If we're at EOF, we have a final, non-terminated line. Return it.
|
|
|
+ if atEOF {
|
|
|
+ return len(data), dropCR(data), nil
|
|
|
+ }
|
|
|
+ // Request more data.
|
|
|
+ return 0, nil, nil
|
|
|
+}
|
|
|
+
|
|
|
+// dropCR drops a terminal \r from the data.
|
|
|
+func dropCR(data []byte) []byte {
|
|
|
+ if len(data) > 0 && data[len(data)-1] == '\r' {
|
|
|
+ return data[0 : len(data)-1]
|
|
|
+ }
|
|
|
+ return data
|
|
|
+}
|
|
|
+
|
|
|
// Start starts the command line interface
|
|
|
func (c *CLI) Start() {
|
|
|
+ // Attempt to fix terminal settings if they are messed up (e.g. missing ICRNL)
|
|
|
+ // This helps when Enter sends \r but the terminal driver expects \n
|
|
|
+ cmd := exec.Command("stty", "sane")
|
|
|
+ cmd.Stdin = os.Stdin
|
|
|
+ cmd.Stdout = os.Stdout
|
|
|
+ _ = cmd.Run()
|
|
|
+
|
|
|
// Boxed startup message
|
|
|
var sb strings.Builder
|
|
|
sb.WriteString(fmt.Sprintf("Node %s%s%s CLI Started\n", ColorGreen, c.server.Raft.nodeID, ColorReset))
|
|
|
sb.WriteString("Type 'help' for commands.")
|
|
|
printBoxed(sb.String())
|
|
|
|
|
|
+ // Initialize history handling
|
|
|
+ historyPath := filepath.Join(os.TempDir(), fmt.Sprintf(".raft_history_%s", c.server.Raft.nodeID))
|
|
|
+ historyFile, err := os.OpenFile(historyPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
|
|
+ if err != nil {
|
|
|
+ // Just warn, don't fail
|
|
|
+ fmt.Printf("%sWarning: Could not open history file: %v%s\n", ColorYellow, err, ColorReset)
|
|
|
+ }
|
|
|
+ defer func() {
|
|
|
+ if historyFile != nil {
|
|
|
+ historyFile.Close()
|
|
|
+ }
|
|
|
+ }()
|
|
|
+
|
|
|
// State Monitor Loop
|
|
|
go func() {
|
|
|
var lastState string
|
|
|
@@ -146,6 +200,7 @@ func (c *CLI) Start() {
|
|
|
}()
|
|
|
|
|
|
scanner := bufio.NewScanner(os.Stdin)
|
|
|
+ scanner.Split(scanCRLF)
|
|
|
fmt.Print("> ")
|
|
|
for scanner.Scan() {
|
|
|
text := strings.TrimSpace(scanner.Text())
|
|
|
@@ -153,6 +208,14 @@ func (c *CLI) Start() {
|
|
|
fmt.Print("> ")
|
|
|
continue
|
|
|
}
|
|
|
+
|
|
|
+ // Save command to history
|
|
|
+ if historyFile != nil {
|
|
|
+ if _, err := historyFile.WriteString(text + "\n"); err != nil {
|
|
|
+ // Ignore write error
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
parts := strings.Fields(text)
|
|
|
cmdName := strings.ToLower(parts[0])
|
|
|
|
|
|
@@ -171,6 +234,8 @@ func (c *CLI) Start() {
|
|
|
infoCmd.Handler(parts, c.server)
|
|
|
}
|
|
|
c.mu.RUnlock()
|
|
|
+ case "history":
|
|
|
+ c.printHistory(historyPath)
|
|
|
default:
|
|
|
printBoxed("Unknown command. Type 'help'.")
|
|
|
}
|
|
|
@@ -179,6 +244,32 @@ func (c *CLI) Start() {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+// printHistory prints the command history
|
|
|
+func (c *CLI) printHistory(path string) {
|
|
|
+ content, err := os.ReadFile(path)
|
|
|
+ if err != nil {
|
|
|
+ printBoxed("No history found.")
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ lines := strings.Split(string(content), "\n")
|
|
|
+ // Show last 20 lines
|
|
|
+ start := 0
|
|
|
+ if len(lines) > 20 {
|
|
|
+ start = len(lines) - 20
|
|
|
+ }
|
|
|
+
|
|
|
+ var sb strings.Builder
|
|
|
+ sb.WriteString(fmt.Sprintf("%sCommand History (Last 20):%s\n", ColorCyan, ColorReset))
|
|
|
+ for i := start; i < len(lines); i++ {
|
|
|
+ line := strings.TrimSpace(lines[i])
|
|
|
+ if line != "" {
|
|
|
+ sb.WriteString(fmt.Sprintf("%d: %s\n", i+1, line))
|
|
|
+ }
|
|
|
+ }
|
|
|
+ printBoxed(sb.String())
|
|
|
+}
|
|
|
+
|
|
|
// RegisterCommand registers a new CLI command
|
|
|
func (c *CLI) RegisterCommand(name, description string, handler func(parts []string, server *KVServer)) {
|
|
|
c.mu.Lock()
|
|
|
@@ -407,6 +498,10 @@ func (c *CLI) registerDefaultCommands() {
|
|
|
os.Exit(0)
|
|
|
})
|
|
|
|
|
|
+ c.RegisterCommand("clear", "Clear screen", func(parts []string, server *KVServer) {
|
|
|
+ fmt.Print("\033[H\033[2J")
|
|
|
+ })
|
|
|
+
|
|
|
c.RegisterCommand("help", "Show commands", func(parts []string, server *KVServer) {
|
|
|
var sb strings.Builder
|
|
|
sb.WriteString(fmt.Sprintf("%sCommands:%s\n", ColorGreen, ColorReset))
|