feat: implemented the panels

This commit is contained in:
Hayden Hargreaves 2026-04-15 12:33:08 -07:00
parent ea201b4c91
commit 958fc7308a
11 changed files with 212 additions and 110 deletions

View File

@ -13,6 +13,7 @@ import (
func StartProcess(cmd model.Command, addr string, ch chan<- model.Event) (*model.Process, error) { func StartProcess(cmd model.Command, addr string, ch chan<- model.Event) (*model.Process, error) {
ch <- model.Event{ ch <- model.Event{
Time: time.Now().Local(),
Type: model.EventTypeProcessStarting, Type: model.EventTypeProcessStarting,
Body: fmt.Sprintf("spawning process '%s'", process.CommandString(cmd)), Body: fmt.Sprintf("spawning process '%s'", process.CommandString(cmd)),
} }
@ -35,6 +36,7 @@ func StopProcess(proc *model.Process, ch chan<- model.Event, sig syscall.Signal)
} }
ch <- model.Event{ ch <- model.Event{
Time: time.Now().Local(),
Type: model.EventTypeProcessSignaled, Type: model.EventTypeProcessSignaled,
Body: fmt.Sprintf("process with pid '%d' is being killed", proc.Exec.Process.Pid), Body: fmt.Sprintf("process with pid '%d' is being killed", proc.Exec.Process.Pid),
PID: proc.Exec.Process.Pid, PID: proc.Exec.Process.Pid,
@ -58,6 +60,7 @@ func waitForProcessExit(proc *model.Process, ch chan<- model.Event) {
if err := proc.Exec.Wait(); err != nil { if err := proc.Exec.Wait(); err != nil {
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
ch <- model.Event{ ch <- model.Event{
Time: time.Now().Local(),
Type: model.EventTypeProcessExited, Type: model.EventTypeProcessExited,
Body: fmt.Sprintf("process pid '%d' exited", proc.Exec.Process.Pid), Body: fmt.Sprintf("process pid '%d' exited", proc.Exec.Process.Pid),
PID: proc.Exec.Process.Pid, PID: proc.Exec.Process.Pid,
@ -68,6 +71,7 @@ func waitForProcessExit(proc *model.Process, ch chan<- model.Event) {
} }
ch <- model.Event{ ch <- model.Event{
Time: time.Now().Local(),
Type: model.EventTypeFatal, Type: model.EventTypeFatal,
Body: fmt.Sprintf("%q", err), Body: fmt.Sprintf("%q", err),
} }
@ -76,6 +80,7 @@ func waitForProcessExit(proc *model.Process, ch chan<- model.Event) {
} }
ch <- model.Event{ ch <- model.Event{
Time: time.Now().Local(),
Type: model.EventTypeProcessExited, Type: model.EventTypeProcessExited,
Body: fmt.Sprintf("process pid '%d' exited", proc.Exec.Process.Pid), Body: fmt.Sprintf("process pid '%d' exited", proc.Exec.Process.Pid),
PID: proc.Exec.Process.Pid, PID: proc.Exec.Process.Pid,

View File

@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"time"
"termtap.dev/internal/model" "termtap.dev/internal/model"
) )
@ -14,6 +15,7 @@ func StartProxy(ps *model.ProxyServer, ch chan<- model.Event) {
} }
ch <- model.Event{ ch <- model.Event{
Time: time.Now().Local(),
Type: model.EventTypeProxyStarting, Type: model.EventTypeProxyStarting,
Body: fmt.Sprintf("proxy server started on %s", (*ps.Listener).Addr().String()), Body: fmt.Sprintf("proxy server started on %s", (*ps.Listener).Addr().String()),
} }
@ -24,6 +26,7 @@ func StartProxy(ps *model.ProxyServer, ch chan<- model.Event) {
} }
ch <- model.Event{ ch <- model.Event{
Time: time.Now().Local(),
Type: model.EventTypeFatal, Type: model.EventTypeFatal,
Body: fmt.Sprintf("fatal error in proxy server: %q", err), Body: fmt.Sprintf("fatal error in proxy server: %q", err),
} }

View File

@ -1,5 +1,7 @@
package model package model
import "time"
type EventType string type EventType string
const ( const (
@ -26,6 +28,7 @@ const (
) )
type Event struct { type Event struct {
Time time.Time
Type EventType Type EventType
Body string Body string
PID int PID int

View File

@ -7,6 +7,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
"time"
"termtap.dev/internal/model" "termtap.dev/internal/model"
) )
@ -24,6 +25,7 @@ func NewProcess(cmd model.Command, addr string, ch chan<- model.Event) *model.Pr
stdout, err := proc.StdoutPipe() stdout, err := proc.StdoutPipe()
if err != nil { if err != nil {
ch <- model.Event{ ch <- model.Event{
Time: time.Now().Local(),
Type: model.EventTypeWarn, Type: model.EventTypeWarn,
Body: fmt.Sprintf("could not open stdout pipe: %q", err), Body: fmt.Sprintf("could not open stdout pipe: %q", err),
PID: proc.Process.Pid, PID: proc.Process.Pid,
@ -35,6 +37,7 @@ func NewProcess(cmd model.Command, addr string, ch chan<- model.Event) *model.Pr
stderr, err := proc.StderrPipe() stderr, err := proc.StderrPipe()
if err != nil { if err != nil {
ch <- model.Event{ ch <- model.Event{
Time: time.Now().Local(),
Type: model.EventTypeWarn, Type: model.EventTypeWarn,
Body: fmt.Sprintf("could not open stderr pipe: %q", err), Body: fmt.Sprintf("could not open stderr pipe: %q", err),
PID: proc.Process.Pid, PID: proc.Process.Pid,
@ -70,6 +73,7 @@ func readPipe(pipe io.Reader, t model.EventType, ch chan<- model.Event) {
scanner := bufio.NewScanner(pipe) scanner := bufio.NewScanner(pipe)
for scanner.Scan() { for scanner.Scan() {
ch <- model.Event{ ch <- model.Event{
Time: time.Now().Local(),
Type: t, Type: t,
Body: scanner.Text(), Body: scanner.Text(),
} }
@ -100,6 +104,7 @@ func UpdateStatus(proc *model.Process, running bool, ch chan<- model.Event) {
} }
ch <- model.Event{ ch <- model.Event{
Time: time.Now().Local(),
Type: t, Type: t,
Body: fmt.Sprintf("Set process pid '%d' status to %s", proc.Exec.Process.Pid, status), Body: fmt.Sprintf("Set process pid '%d' status to %s", proc.Exec.Process.Pid, status),
PID: proc.Exec.Process.Pid, PID: proc.Exec.Process.Pid,

View File

@ -28,6 +28,7 @@ func proxyHandler(ch chan<- model.Event) http.Handler {
if req.Method == http.MethodConnect { if req.Method == http.MethodConnect {
http.Error(w, "CONNECT is not supported yet", http.StatusNotImplemented) http.Error(w, "CONNECT is not supported yet", http.StatusNotImplemented)
ch <- model.Event{ ch <- model.Event{
Time: time.Now().Local(),
Type: model.EventTypeWarn, Type: model.EventTypeWarn,
Body: fmt.Sprintf("CONNECT is not supported: %s", req.Host), Body: fmt.Sprintf("CONNECT is not supported: %s", req.Host),
} }
@ -37,6 +38,7 @@ func proxyHandler(ch chan<- model.Event) http.Handler {
if req.URL.Scheme == "" || req.URL.Host == "" { if req.URL.Scheme == "" || req.URL.Host == "" {
http.Error(w, "request must use absolute-form URLs through the proxy", http.StatusBadRequest) http.Error(w, "request must use absolute-form URLs through the proxy", http.StatusBadRequest)
ch <- model.Event{ ch <- model.Event{
Time: time.Now().Local(),
Type: model.EventTypeWarn, Type: model.EventTypeWarn,
Body: fmt.Sprintf("rejected non-proxy request %s %s", req.Method, req.URL.String()), Body: fmt.Sprintf("rejected non-proxy request %s %s", req.Method, req.URL.String()),
} }
@ -61,6 +63,7 @@ func proxyHandler(ch chan<- model.Event) http.Handler {
requestPreview, err := readAndRestoreBody(&req.Body) requestPreview, err := readAndRestoreBody(&req.Body)
if err != nil { if err != nil {
ch <- model.Event{ ch <- model.Event{
Time: time.Now().Local(),
Type: model.EventTypeWarn, Type: model.EventTypeWarn,
Body: fmt.Sprintf("(%s) failed to read request body", request.ID), Body: fmt.Sprintf("(%s) failed to read request body", request.ID),
Request: request, Request: request,
@ -81,6 +84,7 @@ func proxyHandler(ch chan<- model.Event) http.Handler {
request.RawURL = outReq.URL.String() request.RawURL = outReq.URL.String()
ch <- model.Event{ ch <- model.Event{
Time: time.Now().Local(),
Type: model.EventTypeRequestStarted, Type: model.EventTypeRequestStarted,
Body: fmt.Sprintf("-> %+v", request), Body: fmt.Sprintf("-> %+v", request),
Request: request, Request: request,
@ -97,6 +101,7 @@ func proxyHandler(ch chan<- model.Event) http.Handler {
request.Status = status request.Status = status
ch <- model.Event{ ch <- model.Event{
Time: time.Now().Local(),
Type: model.EventTypeRequestFailed, Type: model.EventTypeRequestFailed,
Body: fmt.Sprintf("upstream error for %s %s: %v", outReq.Method, outReq.URL.String(), err), Body: fmt.Sprintf("upstream error for %s %s: %v", outReq.Method, outReq.URL.String(), err),
Request: request, Request: request,
@ -108,6 +113,7 @@ func proxyHandler(ch chan<- model.Event) http.Handler {
responsePreview, err := readAndRestoreBody(&resp.Body) responsePreview, err := readAndRestoreBody(&resp.Body)
if err != nil { if err != nil {
ch <- model.Event{ ch <- model.Event{
Time: time.Now().Local(),
Type: model.EventTypeWarn, Type: model.EventTypeWarn,
Body: fmt.Sprintf("(%s) failed to read response body", request.ID), Body: fmt.Sprintf("(%s) failed to read response body", request.ID),
Request: request, Request: request,
@ -125,6 +131,7 @@ func proxyHandler(ch chan<- model.Event) http.Handler {
request.Status = resp.StatusCode request.Status = resp.StatusCode
ch <- model.Event{ ch <- model.Event{
Time: time.Now().Local(),
Type: model.EventTypeRequestFailed, Type: model.EventTypeRequestFailed,
Body: fmt.Sprintf("write response body %s %s: %v", outReq.Method, outReq.URL.String(), err), Body: fmt.Sprintf("write response body %s %s: %v", outReq.Method, outReq.URL.String(), err),
} }
@ -137,6 +144,7 @@ func proxyHandler(ch chan<- model.Event) http.Handler {
request.Pending = false request.Pending = false
ch <- model.Event{ ch <- model.Event{
Time: time.Now().Local(),
Type: model.EventTypeRequestFinished, Type: model.EventTypeRequestFinished,
Body: fmt.Sprintf("<- %+v %s", request, formatHeaders(resp.Request.Header)), Body: fmt.Sprintf("<- %+v %s", request, formatHeaders(resp.Request.Header)),
Request: request, Request: request,

View File

@ -35,6 +35,7 @@ func Destroy(ps *model.ProxyServer, ch chan<- model.Event) {
if ps != nil && ps.Server != nil { if ps != nil && ps.Server != nil {
_ = ps.Server.Shutdown(ctx) _ = ps.Server.Shutdown(ctx)
ch <- model.Event{ ch <- model.Event{
Time: time.Now().Local(),
Type: model.EventTypeProxyStarted, Type: model.EventTypeProxyStarted,
Body: "proxy server was destroyed", Body: "proxy server was destroyed",
} }

View File

@ -1,6 +1,11 @@
package tui package tui
import "termtap.dev/internal/model" import (
"time"
tea "github.com/charmbracelet/bubbletea"
"termtap.dev/internal/model"
)
type EventMsg struct { type EventMsg struct {
value model.Event value model.Event
@ -9,3 +14,15 @@ type EventMsg struct {
type ErrMsg struct { type ErrMsg struct {
err error err error
} }
type TickMsg struct {
Now time.Time
}
const tick = 20 * time.Millisecond
func tickCmd() tea.Cmd {
return tea.Tick(tick, func(t time.Time) tea.Msg {
return TickMsg{Now: t}
})
}

View File

@ -2,6 +2,7 @@ package tui
import ( import (
"fmt" "fmt"
"time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"termtap.dev/internal/model" "termtap.dev/internal/model"
@ -25,6 +26,8 @@ type Model struct {
showEvents bool showEvents bool
showStd bool showStd bool
showSearch bool showSearch bool
now time.Time
} }
func NewModel(ch <-chan model.Event) Model { func NewModel(ch <-chan model.Event) Model {
@ -47,7 +50,7 @@ func Run(ch <-chan model.Event) error {
} }
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
return waitForEvent(m.channel) return tea.Batch(waitForEvent(m.channel), tickCmd())
} }
func waitForEvent(ch <-chan model.Event) tea.Cmd { func waitForEvent(ch <-chan model.Event) tea.Cmd {

View File

@ -3,10 +3,12 @@ package tui
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
"termtap.dev/internal/model"
) )
func (m Model) renderStatusBar(w int) string { func (m Model) renderStatusBar(w int) string {
// TODO: Optimize somehow
var errCount int var errCount int
for _, req := range m.requests { for _, req := range m.requests {
if req.Failed || (req.Status >= 400 && req.Status < 600) { if req.Failed || (req.Status >= 400 && req.Status < 600) {
@ -32,28 +34,58 @@ func (m Model) renderSearchPane(w, h int) []string {
} }
func (m Model) renderRequestPane(w, h int) []string { func (m Model) renderRequestPane(w, h int) []string {
if w < 0 { var lines []string
w = 0
// Render header
headerLeft := fmt.Sprintf(" %-7s %-24s %s", "METHOD", "HOST", "PATH")
headerRight := fmt.Sprintf("%4s %8s ", "CODE", "TIME")
headerSpace := strings.Repeat(" ", max(0, w-len(headerLeft+headerRight)))
header := headerLeft + headerSpace + headerRight
lines = append(lines, header)
for i := len(m.requests) - 1; i >= 0; i-- {
req := m.requests[i]
// Some formatting magic here maybe
left := fmt.Sprintf(
" %-7s %-24s %s",
strings.ToUpper(req.Method),
req.Host,
req.URL,
)
right := fmt.Sprintf(
"%4d %8s ",
req.Status,
formatDuration(req.Duration),
)
if req.Pending && !req.StartTime.IsZero() {
right = fmt.Sprintf(
"%4s %8s ",
"",
formatDuration(time.Since(req.StartTime)),
)
} }
if h < 0 { space := strings.Repeat(" ", max(0, w-len(left+right)))
h = 0
line := left + space + right
lines = append(lines, line)
} }
lines := make([]string, h) // Cleanup
for y := range lines { if len(lines) < h {
lines[y] = strings.Repeat(".", w) for i := len(lines); i < h; i++ {
lines = append(lines, strings.Repeat(" ", w))
} }
}
if len(lines) > h {
lines = lines[:h]
}
return lines return lines
} }
func (m Model) renderDetailsPane(w, h int) []string { func (m Model) renderDetailsPane(w, h int) []string {
if w < 0 {
w = 0
}
if h < 0 {
h = 0
}
lines := make([]string, h) lines := make([]string, h)
for y := range lines { for y := range lines {
lines[y] = strings.Repeat("^", w) lines[y] = strings.Repeat("^", w)
@ -61,32 +93,101 @@ func (m Model) renderDetailsPane(w, h int) []string {
return lines return lines
} }
// TODO: This can be done better
// TODO: Should h be max or defined?
func (m Model) renderEventsPane(w, h int) []string { func (m Model) renderEventsPane(w, h int) []string {
if w < 0 { // Remove the stdout or stderr logs
w = 0 var events []model.Event
for _, ev := range m.events {
if ev.Type != model.EventTypeProcessStderr &&
ev.Type != model.EventTypeProcessStdout {
events = append(events, ev)
} }
if h < 0 {
h = 0
} }
lines := make([]string, h) displayCount := max(h-1, 0)
for y := range lines {
lines[y] = strings.Repeat("~", w) if displayCount < len(events) {
events = events[len(events)-displayCount:]
} }
lines := []string{
fmt.Sprintf("EVENT LOG - %d EVENTS", len(events)),
}
for _, event := range events {
line := fmt.Sprintf(
"%s %-15s %s",
event.Time.Format("15:04:05"),
event.Type,
event.Body,
)
if event.PID > 0 {
line = fmt.Sprintf(
"%s %-15s %d %s",
event.Time.Format("15:04:05"),
event.Type,
event.PID,
event.Body,
)
}
lines = append(lines, truncate(line, w))
}
// Cleanup
if len(lines) < h {
for i := len(lines); i < h; i++ {
lines = append(lines, "")
}
}
return lines return lines
} }
// TODO: Should h be max or defined?
func (m Model) renderStdPane(w, h int) []string { func (m Model) renderStdPane(w, h int) []string {
if w < 0 { // Only the stdout or stderr logs
w = 0 var logs []model.Event
for _, ev := range m.events {
if ev.Type == model.EventTypeProcessStderr ||
ev.Type == model.EventTypeProcessStdout {
logs = append(logs, ev)
} }
if h < 0 {
h = 0
} }
lines := make([]string, h) displayCount := max(h-1, 0)
for y := range lines {
lines[y] = strings.Repeat(" ", w) if displayCount < len(logs) {
logs = logs[len(logs)-displayCount:]
} }
lines := []string{
fmt.Sprintf("STDOUT/STDERR LOG - %d LINES", len(logs)),
}
for _, log := range logs {
var t string
if log.Type == model.EventTypeProcessStderr {
t = "STDERR"
}
if log.Type == model.EventTypeProcessStdout {
t = "STDOUT"
}
line := fmt.Sprintf(
"%s %6s %s",
log.Time.Format("15:04:05"),
t,
log.Body,
)
lines = append(lines, truncate(line, w))
}
// Cleanup
if len(lines) < h {
for i := len(lines); i < h; i++ {
lines = append(lines, "")
}
}
return lines return lines
} }

View File

@ -2,6 +2,7 @@ package tui
import ( import (
"fmt" "fmt"
"time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"termtap.dev/internal/model" "termtap.dev/internal/model"
@ -14,6 +15,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.height = msg.Height m.height = msg.Height
return m, nil return m, nil
case TickMsg:
m.now = msg.Now
if m.hasPendingRequests() {
return m, tickCmd()
}
return m, nil
// TODO: Abstract the keymaps // TODO: Abstract the keymaps
case tea.KeyMsg: case tea.KeyMsg:
switch msg.String() { switch msg.String() {
@ -32,6 +40,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case ErrMsg: case ErrMsg:
m.events = append(m.events, model.Event{ m.events = append(m.events, model.Event{
Time: time.Now().Local(),
Type: model.EventTypeWarn, Type: model.EventTypeWarn,
Body: fmt.Sprintf("tui event stream closed: %v", msg.err), Body: fmt.Sprintf("tui event stream closed: %v", msg.err),
}) })
@ -40,6 +49,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case EventMsg: case EventMsg:
m.pushEvent(msg.value) m.pushEvent(msg.value)
m.applyMessage(msg.value) m.applyMessage(msg.value)
if m.hasPendingRequests() {
return m, tea.Batch(waitForEvent(m.channel), tickCmd())
}
return m, waitForEvent(m.channel) return m, waitForEvent(m.channel)
} }
@ -82,3 +94,14 @@ func (m *Model) updateRequest(req model.Request) {
} }
} }
} }
func (m Model) hasPendingRequests() bool {
// Traverse backward to be a bit more efficient, the most recent requests are more
// like to be pending.
for i := len(m.requests) - 1; i >= 0; i-- {
if m.requests[i].Pending {
return true
}
}
return false
}

View File

@ -2,95 +2,28 @@ package tui
import ( import (
"fmt" "fmt"
"strings" "time"
"termtap.dev/internal/model"
) )
// TODO: This is all temporary // TODO: This is all temporary
func (m Model) View() string { func (m Model) View() string {
return m.renderAppPane() return m.renderAppPane()
// eventLines := m.renderEvents(8)
// requestLines := m.renderRequests(12)
//
// return strings.Join([]string{
// "termtap - live session",
// fmt.Sprintf("events=%d requests=%d", len(m.events), len(m.requests)),
// fmt.Sprintf("%dx%d", m.height, m.width),
// "keys: q/esc/ctrl+c quit",
// "",
// "Recent events:",
// eventLines,
// "",
// "Recent requests:",
// requestLines,
// }, "\n")
} }
func (m Model) renderEvents(limit int) string { func formatDuration(d time.Duration) string {
if len(m.events) == 0 { if d == 0 {
return " (none yet)" return "PENDING"
} }
start := max(len(m.events)-limit, 0) if d >= 10*time.Second {
return fmt.Sprintf("%.2fs", d.Seconds())
rows := make([]string, 0, len(m.events)-start)
for i := start; i < len(m.events); i++ {
e := m.events[i]
rows = append(rows, fmt.Sprintf(" [%s] %s", e.Type, truncate(e.Body, 100)))
} }
return strings.Join(rows, "\n") if d >= time.Millisecond {
} return fmt.Sprintf("%dms", d.Milliseconds())
func (m Model) renderRequests(limit int) string {
if len(m.requests) == 0 {
return " (none yet)"
} }
start := max(0, len(m.requests)-limit) return fmt.Sprintf("%dus", d.Microseconds())
// Traverse backwards since we don't have a stack
rows := make([]string, 0, len(m.requests)-start)
for i := len(m.requests) - 1; i >= start; i-- {
req := m.requests[i]
state := "done"
if req.Pending {
state = "pending"
} else if req.Failed {
state = "failed"
}
rows = append(rows, fmt.Sprintf(
" %s %s status=%d duration=%s state=%s",
req.Method,
requestPath(req),
req.Status,
req.Duration,
state,
))
}
if len(rows) == 0 {
return " (none yet)"
}
return strings.Join(rows, "\n")
}
func requestPath(req model.Request) string {
if req.URL != "" {
return truncate(req.URL, 80)
}
if req.RawURL != "" {
return truncate(req.RawURL, 80)
}
if req.Host != "" {
return truncate(req.Host, 80)
}
return "<unknown>"
} }
func truncate(s string, max int) string { func truncate(s string, max int) string {