Files
pxmon/internal/cli/monitor_ssh.go
T
2026-06-16 21:52:10 +04:00

467 lines
12 KiB
Go

package cli
import (
"context"
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/hinshun/vt10x"
"pxmon/internal/cluster"
)
// sshStartedMsg is dispatched when an embedded SSH session finishes dialing.
type sshStartedMsg struct {
session *cluster.InteractiveSession
cluster cluster.Cluster
cols int
rows int
err error
}
// sshChunkMsg delivers a chunk of remote PTY output to the model.
type sshChunkMsg struct {
data []byte
err error
}
// sshClosedMsg signals that the embedded session was torn down.
type sshClosedMsg struct {
err error
}
// sshTickMsg throttles vt10x → view repaints while output is flowing.
type sshTickMsg struct{}
const (
sshMinCols = 20
sshMinRows = 5
sshChunkBuffer = 16384
sshRepaintInterval = 33 * time.Millisecond
)
// sshEmbeddedDims returns the usable grid size for the SSH panel.
func (m monitorModel) sshEmbeddedDims() (int, int) {
w := m.width
if w <= 0 {
w = 120
}
h := m.height
if h <= 0 {
h = 32
}
cols := w - 4
rows := h - 4
if cols < sshMinCols {
cols = sshMinCols
}
if rows < sshMinRows {
rows = sshMinRows
}
return cols, rows
}
// startEmbeddedSSHCmd kicks off an SSH dial in a goroutine and returns
// the started session through an sshStartedMsg.
func (m monitorModel) startEmbeddedSSHCmd(selector string) tea.Cmd {
svc := m.svc
cols, rows := m.sshEmbeddedDims()
c, err := svc.Get(selector)
if err != nil {
return func() tea.Msg { return sshStartedMsg{err: err} }
}
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
sess, err := svc.StartInteractiveShell(ctx, c.ID, cols, rows, "xterm-256color")
if err != nil {
return sshStartedMsg{err: err, cluster: c}
}
return sshStartedMsg{
session: sess,
cluster: c,
cols: cols,
rows: rows,
}
}
}
// readSSHChunkCmd reads the next chunk from the SSH session in a goroutine.
func readSSHChunkCmd(sess *cluster.InteractiveSession) tea.Cmd {
if sess == nil {
return nil
}
return func() tea.Msg {
buf := make([]byte, sshChunkBuffer)
n, err := sess.Read(buf)
if n > 0 {
data := make([]byte, n)
copy(data, buf[:n])
return sshChunkMsg{data: data, err: err}
}
return sshChunkMsg{err: err}
}
}
// sshRepaintTickCmd schedules a throttled repaint while output flows.
func sshRepaintTickCmd() tea.Cmd {
return tea.Tick(sshRepaintInterval, func(_ time.Time) tea.Msg {
return sshTickMsg{}
})
}
// enterEmbeddedSSH switches the model into embedded SSH view for the
// freshly dialed session.
func (m *monitorModel) enterEmbeddedSSH(msg sshStartedMsg) {
cols := msg.cols
rows := msg.rows
if cols < sshMinCols {
cols = sshMinCols
}
if rows < sshMinRows {
rows = sshMinRows
}
vt := vt10x.New(vt10x.WithSize(cols, rows))
m.sshMode = true
m.sshSess = msg.session
m.sshCluster = msg.cluster
m.sshVT = vt
m.sshCols = cols
m.sshRows = rows
m.sshClosed = false
m.sshErr = ""
m.sshPendingRepaint = false
}
// exitEmbeddedSSH tears down the embedded session and clears state.
func (m *monitorModel) exitEmbeddedSSH(reason string) {
if m.sshSess != nil {
_ = m.sshSess.Close()
}
m.sshSess = nil
m.sshVT = nil
m.sshMode = false
m.sshClosed = true
m.sshPendingRepaint = false
// Return to pxmon console so the user lands where they launched from.
m.termMode = true
m.termFull = false
if reason != "" {
m.setStatus(reason)
}
}
// writeSSHInput pushes a byte slice to the SSH session's stdin.
func (m monitorModel) writeSSHInput(p []byte) {
if m.sshSess == nil || len(p) == 0 {
return
}
_, _ = m.sshSess.Write(p)
}
// handleSSHKey routes TUI key events into the remote PTY stdin.
func (m monitorModel) handleSSHKey(v tea.KeyMsg) (tea.Model, tea.Cmd) {
// Escape hatch: Ctrl+] closes the embedded session.
if v.Type == tea.KeyCtrlCloseBracket {
m.exitEmbeddedSSH(fmt.Sprintf("ssh closed (%s)", m.sshCluster.Name))
m.appendConsoleOutput(fmt.Sprintf("ssh session closed (%s)", m.sshCluster.Name))
return m, nil
}
// Scrollback controls — mirror the pxmon console (alt+↑/↓, pgup/pgdn,
// alt+home/end). Alt+Shift+↑/↓ jump 10 lines at a time for fast review.
// These never reach the remote PTY.
switch v.String() {
case "alt+up":
m.scrollSSH(1)
return m, nil
case "alt+down":
m.scrollSSH(-1)
return m, nil
case "alt+shift+up":
m.scrollSSH(10)
return m, nil
case "alt+shift+down":
m.scrollSSH(-10)
return m, nil
case "pgup":
m.scrollSSH(m.sshRows - 1)
return m, nil
case "pgdown":
m.scrollSSH(-(m.sshRows - 1))
return m, nil
case "alt+home":
m.sshScrollOffset = len(m.sshScrollback)
m.clampSSHScroll()
return m, nil
case "alt+end":
m.sshScrollOffset = 0
return m, nil
case "alt+p":
m.privacyMode = !m.privacyMode
if m.privacyMode {
m.setStatus("privacy: on")
} else {
m.setStatus("privacy: off")
}
return m, nil
}
// Any other input resumes live view if the user was scrolling back.
if m.sshScrollOffset != 0 {
m.sshScrollOffset = 0
}
payload := keyMsgToPTY(v)
if len(payload) == 0 {
return m, nil
}
m.writeSSHInput(payload)
return m, nil
}
// captureSSHScrollback appends remote output (with ANSI sequences stripped)
// to the scrollback buffer so the user can scroll through past output even
// though vt10x does not retain a scroll history of its own.
func (m *monitorModel) captureSSHScrollback(data []byte) {
if len(data) == 0 {
return
}
text := stripANSI(string(data))
// Treat stand-alone CR as a rewrite of the current line; drop content
// before the CR to avoid duplicating progress-bar style updates.
combined := m.sshScrollPending + text
combined = strings.ReplaceAll(combined, "\r\n", "\n")
lines := strings.Split(combined, "\n")
// Last element is either a trailing newline ("") or a partial line; stash.
m.sshScrollPending = lines[len(lines)-1]
lines = lines[:len(lines)-1]
for _, ln := range lines {
if idx := strings.LastIndex(ln, "\r"); idx >= 0 {
ln = ln[idx+1:]
}
m.sshScrollback = append(m.sshScrollback, ln)
}
const maxScrollback = 5000
if len(m.sshScrollback) > maxScrollback {
m.sshScrollback = m.sshScrollback[len(m.sshScrollback)-maxScrollback:]
}
// A new chunk means more history arrived behind the current scroll
// window — adjust offset so the user keeps looking at the same line.
if m.sshScrollOffset > 0 {
m.sshScrollOffset += len(lines)
m.clampSSHScroll()
}
}
func (m *monitorModel) scrollSSH(delta int) {
m.sshScrollOffset += delta
m.clampSSHScroll()
}
func (m *monitorModel) clampSSHScroll() {
maxOffset := len(m.sshScrollback) - m.sshRows
if maxOffset < 0 {
maxOffset = 0
}
if m.sshScrollOffset > maxOffset {
m.sshScrollOffset = maxOffset
}
if m.sshScrollOffset < 0 {
m.sshScrollOffset = 0
}
}
// handleSSHResize adjusts the vt10x grid and notifies the remote side.
func (m *monitorModel) handleSSHResize() {
if !m.sshMode || m.sshVT == nil || m.sshSess == nil {
return
}
cols, rows := m.sshEmbeddedDims()
if cols == m.sshCols && rows == m.sshRows {
return
}
m.sshCols = cols
m.sshRows = rows
m.sshVT.Resize(cols, rows)
_ = m.sshSess.Resize(cols, rows)
}
// renderSSHView produces the TUI view for the embedded SSH session.
func (m monitorModel) renderSSHView() string {
header := accentStyle.Bold(true).Render(fmt.Sprintf(" ssh://%s@%s:%d (%s) ", m.sshCluster.User, m.sshCluster.Host, m.sshCluster.Port, m.sshCluster.Name))
hint := dimStyle.Render("Ctrl+] detach · alt+↑/↓ scroll · pgup/pgdn page · alt+end live")
if m.sshScrollOffset > 0 {
hint = warnStyle.Render(fmt.Sprintf("↑ scrolled +%d (alt+end to follow)", m.sshScrollOffset)) +
dimStyle.Render(" Ctrl+] detach")
}
body := m.renderVTBody()
panel := lipgloss.NewStyle().
BorderStyle(thinBorder).
BorderForeground(ccAccent).
Padding(0, 1).
Render(body)
status := dimStyle.Render(strings.TrimSpace("status: " + m.statusMsg))
return lipgloss.JoinVertical(lipgloss.Left, header+" "+hint, panel, status)
}
// renderVTBody iterates the vt10x grid and emits a styled string block.
// When the user has scrolled back it instead renders a window of the
// ANSI-stripped scrollback buffer.
func (m monitorModel) renderVTBody() string {
if m.sshVT == nil {
return dimStyle.Render("(session not ready)")
}
if m.sshScrollOffset > 0 {
return m.renderScrollbackBody()
}
vt := m.sshVT
vt.Lock()
cols, rows := vt.Size()
cur := vt.Cursor()
cursorVisible := vt.CursorVisible()
var lines []string
for y := 0; y < rows; y++ {
line := renderVTRow(vt, y, cols, cursorVisible, cur.X, cur.Y)
lines = append(lines, line)
}
vt.Unlock()
return strings.Join(lines, "\n")
}
// renderScrollbackBody paints a page of the captured scrollback buffer when
// the user is browsing history. Every row is padded to the full grid width
// so the panel keeps its live dimensions — otherwise lipgloss sizes the
// border to the longest line and the view visibly collapses.
func (m monitorModel) renderScrollbackBody() string {
rows := m.sshRows
cols := m.sshCols
if rows <= 0 {
rows = sshMinRows
}
if cols <= 0 {
cols = sshMinCols
}
end := len(m.sshScrollback) - m.sshScrollOffset
if end < 0 {
end = 0
}
start := end - rows
if start < 0 {
start = 0
}
blank := strings.Repeat(" ", cols)
out := make([]string, 0, rows)
pad := func(line string) string {
rs := []rune(line)
if len(rs) > cols {
rs = rs[:cols]
}
if len(rs) < cols {
return string(rs) + strings.Repeat(" ", cols-len(rs))
}
return string(rs)
}
for i := start; i < end; i++ {
out = append(out, pad(m.sshScrollback[i]))
}
for len(out) < rows {
out = append(out, blank)
}
return strings.Join(out, "\n")
}
// renderVTRow renders a single grid row as a styled string.
func renderVTRow(vt vt10x.Terminal, row, cols int, cursorVisible bool, cursorX, cursorY int) string {
var b strings.Builder
var (
runRunes strings.Builder
runFG vt10x.Color = vt10x.DefaultFG
runBG vt10x.Color = vt10x.DefaultBG
runStart = true
)
flush := func() {
if runRunes.Len() == 0 {
return
}
style := styleForColors(runFG, runBG)
b.WriteString(style.Render(runRunes.String()))
runRunes.Reset()
}
for x := 0; x < cols; x++ {
cell := vt.Cell(x, row)
ch := cell.Char
if ch == 0 {
ch = ' '
}
fg := cell.FG
bg := cell.BG
isCursor := cursorVisible && x == cursorX && row == cursorY
if runStart {
runFG = fg
runBG = bg
runStart = false
}
if isCursor {
// Emit the current run first, then paint the cursor cell with
// an explicit, always-visible color so the caret shows even on
// empty cells with default FG/BG (where a plain invert would
// collapse to the same color).
flush()
cursorStyle := lipgloss.NewStyle().
Background(ccAccent).
Foreground(lipgloss.Color("#101010")).
Bold(true)
b.WriteString(cursorStyle.Render(string(ch)))
runFG = fg
runBG = bg
continue
}
if fg != runFG || bg != runBG {
flush()
runFG = fg
runBG = bg
}
runRunes.WriteRune(ch)
}
flush()
return b.String()
}
// styleForColors returns a lipgloss style for the given vt10x colors.
func styleForColors(fg, bg vt10x.Color) lipgloss.Style {
style := lipgloss.NewStyle()
if c, ok := vtColorToLipgloss(fg); ok {
style = style.Foreground(c)
}
if c, ok := vtColorToLipgloss(bg); ok {
style = style.Background(c)
}
return style
}
// vtColorToLipgloss maps a vt10x color to a lipgloss color. Returns ok=false
// for default so the caller leaves the attribute unset.
func vtColorToLipgloss(c vt10x.Color) (lipgloss.TerminalColor, bool) {
if c == vt10x.DefaultFG || c == vt10x.DefaultBG || c == vt10x.DefaultCursor {
return nil, false
}
// ANSI basic 16
if c < 16 {
return lipgloss.Color(fmt.Sprintf("%d", uint32(c))), true
}
// 256-color palette
if c < 256 {
return lipgloss.Color(fmt.Sprintf("%d", uint32(c))), true
}
// Truecolor (24-bit): stored in low 24 bits.
r := (uint32(c) >> 16) & 0xff
g := (uint32(c) >> 8) & 0xff
bl := uint32(c) & 0xff
return lipgloss.Color(fmt.Sprintf("#%02x%02x%02x", r, g, bl)), true
}