chore: publish pxmon v0.2.0
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user