287 lines
7.0 KiB
Go
287 lines
7.0 KiB
Go
package cli
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"pxmon/internal/cluster"
|
|
)
|
|
|
|
type shellStatus struct {
|
|
ActiveCluster string
|
|
ClusterCount int
|
|
LoadError error
|
|
}
|
|
|
|
func (a *App) runShell(configPath string, jsonOut bool) int {
|
|
status := a.loadShellStatus(configPath)
|
|
a.printShellBanner(status, configPath, jsonOut)
|
|
|
|
history := make([]string, 0, 64)
|
|
reader := bufio.NewReader(os.Stdin)
|
|
|
|
for {
|
|
status = a.loadShellStatus(configPath)
|
|
fmt.Fprint(a.out, status.prompt())
|
|
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
fmt.Fprintln(a.out)
|
|
return 0
|
|
}
|
|
fmt.Fprintf(a.err, "shell input error: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
history = append(history, line)
|
|
|
|
args, parseErr := parseShellArgs(line)
|
|
if parseErr != nil {
|
|
fmt.Fprintf(a.err, "parse error: %v\n", parseErr)
|
|
continue
|
|
}
|
|
if len(args) == 0 {
|
|
continue
|
|
}
|
|
if strings.EqualFold(args[0], "pxmon") || strings.EqualFold(args[0], "pxmon") {
|
|
args = args[1:]
|
|
}
|
|
if len(args) == 0 {
|
|
continue
|
|
}
|
|
|
|
switch shellToken(args[0]) {
|
|
case "exit", "quit", "q", ":q":
|
|
return 0
|
|
case "clear", "cls":
|
|
fmt.Fprint(a.out, "\x1b[2J\x1b[H")
|
|
continue
|
|
case "history":
|
|
for i, h := range history {
|
|
fmt.Fprintf(a.out, "%3d %s\n", i+1, h)
|
|
}
|
|
continue
|
|
case "help", "?":
|
|
a.printShellHelp()
|
|
continue
|
|
case "status", "dashboard":
|
|
a.printShellStatus(status, configPath, jsonOut)
|
|
continue
|
|
}
|
|
|
|
cmd := expandShellCommand(args)
|
|
code := a.runRootCommand(cmd, configPath, jsonOut, false)
|
|
if code != 0 {
|
|
fmt.Fprintf(a.err, "[exit %d]\n", code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *App) loadShellStatus(configPath string) shellStatus {
|
|
status := shellStatus{}
|
|
|
|
store, err := cluster.NewStore(configPath)
|
|
if err != nil {
|
|
status.LoadError = err
|
|
return status
|
|
}
|
|
svc := cluster.NewService(store)
|
|
|
|
clusters, _, err := svc.List()
|
|
if err != nil {
|
|
status.LoadError = err
|
|
return status
|
|
}
|
|
status.ClusterCount = len(clusters)
|
|
|
|
current, err := svc.Current()
|
|
if err == nil {
|
|
status.ActiveCluster = current.Name
|
|
return status
|
|
}
|
|
if !errors.Is(err, cluster.ErrNoActiveCluster) {
|
|
status.LoadError = err
|
|
}
|
|
|
|
return status
|
|
}
|
|
|
|
func (s shellStatus) prompt() string {
|
|
active := strings.TrimSpace(s.ActiveCluster)
|
|
if active == "" {
|
|
active = "none"
|
|
}
|
|
active = strings.ReplaceAll(active, "]", "_")
|
|
return fmt.Sprintf("pxmon[%s]> ", active)
|
|
}
|
|
|
|
func (a *App) printShellBanner(status shellStatus, configPath string, jsonOut bool) {
|
|
fmt.Fprintln(a.out, "+----------------------------------------------------------------+")
|
|
fmt.Fprintln(a.out, "| pxmon interactive shell |")
|
|
fmt.Fprintln(a.out, "| slash: /help /clusters /use <name> /stats [name] /network |")
|
|
fmt.Fprintln(a.out, "| exit: /quit or /q |")
|
|
fmt.Fprintln(a.out, "+----------------------------------------------------------------+")
|
|
a.printShellStatus(status, configPath, jsonOut)
|
|
fmt.Fprintln(a.out)
|
|
}
|
|
|
|
func (a *App) printShellStatus(status shellStatus, configPath string, jsonOut bool) {
|
|
active := strings.TrimSpace(status.ActiveCluster)
|
|
if active == "" {
|
|
active = "none"
|
|
}
|
|
|
|
jsonState := "off"
|
|
if jsonOut {
|
|
jsonState = "on"
|
|
}
|
|
|
|
fmt.Fprintf(a.out, "Clusters: %d | Active: %s | JSON: %s\n", status.ClusterCount, active, jsonState)
|
|
if strings.TrimSpace(configPath) != "" {
|
|
fmt.Fprintf(a.out, "Config: %s\n", configPath)
|
|
}
|
|
if status.LoadError != nil {
|
|
fmt.Fprintf(a.out, "Status error: %v\n", status.LoadError)
|
|
}
|
|
}
|
|
|
|
func (a *App) printShellHelp() {
|
|
fmt.Fprintln(a.out, "Shell commands:")
|
|
fmt.Fprintln(a.out, " help Show this help")
|
|
fmt.Fprintln(a.out, " status Show shell status")
|
|
fmt.Fprintln(a.out, " history Show command history")
|
|
fmt.Fprintln(a.out, " clear Clear screen")
|
|
fmt.Fprintln(a.out, " exit | quit | q Exit shell")
|
|
fmt.Fprintln(a.out)
|
|
fmt.Fprintln(a.out, "Shortcuts:")
|
|
fmt.Fprintln(a.out, " /clusters Alias for 'cluster list'")
|
|
fmt.Fprintln(a.out, " /stats [name] Alias for 'cluster stats [name]'")
|
|
fmt.Fprintln(a.out, " /monitor [name] Alias for 'cluster stats [name]'")
|
|
fmt.Fprintln(a.out, " /network [name] Open network-focused TUI")
|
|
fmt.Fprintln(a.out, " /connect ... Alias for 'cluster connect ...'")
|
|
fmt.Fprintln(a.out, " /use <name> Alias for 'cluster use <name>'")
|
|
fmt.Fprintln(a.out, " /alert ... Alias for 'cluster alert ...'")
|
|
fmt.Fprintln(a.out)
|
|
fmt.Fprintln(a.out, "Examples:")
|
|
fmt.Fprintln(a.out, " /clusters")
|
|
fmt.Fprintln(a.out, " /connect --name eu-1 --host 10.0.0.10 --user root --auth key --key-path ~/.ssh/id_ed25519")
|
|
fmt.Fprintln(a.out, " /bootstrap eu-1")
|
|
fmt.Fprintln(a.out, " /stats eu-1")
|
|
fmt.Fprintln(a.out, " /network eu-1")
|
|
fmt.Fprintln(a.out, " /alert set eu-1 --net-mbps 300 --ram 90 --disk 90")
|
|
fmt.Fprintln(a.out, " /alert set eu-1 --net-sustain-enabled=true --net-sustain-mbps 500 --net-sustain-mins 60 --net-sustain-include net0 --net-sustain-exclude backup")
|
|
}
|
|
|
|
func shellToken(v string) string {
|
|
token := strings.TrimSpace(strings.ToLower(v))
|
|
if strings.HasPrefix(token, "/") {
|
|
token = strings.TrimPrefix(token, "/")
|
|
}
|
|
return token
|
|
}
|
|
|
|
func expandShellCommand(args []string) []string {
|
|
if len(args) == 0 {
|
|
return args
|
|
}
|
|
|
|
out := append([]string(nil), args...)
|
|
cmd := shellToken(out[0])
|
|
if cmd == "" {
|
|
return out
|
|
}
|
|
out[0] = cmd
|
|
|
|
switch cmd {
|
|
case "clusters", "nodes":
|
|
return append([]string{"cluster", "list"}, out[1:]...)
|
|
case "stats", "monitor", "watch":
|
|
return append([]string{"cluster", "stats"}, out[1:]...)
|
|
case "alerts":
|
|
return append([]string{"cluster", "alert"}, out[1:]...)
|
|
}
|
|
|
|
if isClusterShortcut(cmd) {
|
|
return append([]string{"cluster", cmd}, out[1:]...)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func isClusterShortcut(cmd string) bool {
|
|
switch cmd {
|
|
case "connect", "add", "list", "ls", "show", "get", "current", "use",
|
|
"ping", "check", "bootstrap", "disconnect", "remove", "rm", "alert":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func parseShellArgs(line string) ([]string, error) {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
args := make([]string, 0, 8)
|
|
var current strings.Builder
|
|
var quote rune
|
|
escaped := false
|
|
tokenStarted := false
|
|
|
|
flush := func() {
|
|
if tokenStarted {
|
|
args = append(args, current.String())
|
|
current.Reset()
|
|
tokenStarted = false
|
|
}
|
|
}
|
|
|
|
for _, r := range line {
|
|
switch {
|
|
case escaped:
|
|
current.WriteRune(r)
|
|
escaped = false
|
|
tokenStarted = true
|
|
case r == '\\':
|
|
escaped = true
|
|
tokenStarted = true
|
|
case quote != 0:
|
|
if r == quote {
|
|
quote = 0
|
|
} else {
|
|
current.WriteRune(r)
|
|
}
|
|
tokenStarted = true
|
|
case r == '\'' || r == '"':
|
|
quote = r
|
|
tokenStarted = true
|
|
case unicode.IsSpace(r):
|
|
flush()
|
|
default:
|
|
current.WriteRune(r)
|
|
tokenStarted = true
|
|
}
|
|
}
|
|
|
|
if escaped {
|
|
return nil, errors.New("unterminated escape at end of command")
|
|
}
|
|
if quote != 0 {
|
|
return nil, errors.New("unterminated quoted string")
|
|
}
|
|
flush()
|
|
return args, nil
|
|
}
|