Skip to content

Commit 25480e2

Browse files
feat: bubbletea TUI dashboard behind --tui
1 parent b8ba8ed commit 25480e2

3 files changed

Lines changed: 253 additions & 7 deletions

File tree

go.mod

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,29 @@ module github.com/ArpitRajputGithub/httpsdev
22

33
go 1.26.5
44

5-
require github.com/fatih/color v1.19.0
5+
require (
6+
github.com/charmbracelet/bubbletea v1.3.10
7+
github.com/charmbracelet/lipgloss v1.1.0
8+
github.com/fatih/color v1.19.0
9+
)
610

711
require (
12+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
13+
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
14+
github.com/charmbracelet/x/ansi v0.10.1 // indirect
15+
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
16+
github.com/charmbracelet/x/term v0.2.1 // indirect
17+
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
18+
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
819
github.com/mattn/go-colorable v0.1.14 // indirect
920
github.com/mattn/go-isatty v0.0.20 // indirect
21+
github.com/mattn/go-localereader v0.0.1 // indirect
22+
github.com/mattn/go-runewidth v0.0.16 // indirect
23+
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
24+
github.com/muesli/cancelreader v0.2.2 // indirect
25+
github.com/muesli/termenv v0.16.0 // indirect
26+
github.com/rivo/uniseg v0.4.7 // indirect
27+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
1028
golang.org/x/sys v0.42.0 // indirect
29+
golang.org/x/text v0.3.8 // indirect
1130
)

go.sum

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,47 @@
1+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
2+
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
3+
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
4+
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
5+
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
6+
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
7+
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
8+
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
9+
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
10+
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
11+
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
12+
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
13+
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
14+
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
15+
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
16+
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
117
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
218
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
19+
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
20+
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
321
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
422
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
523
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
624
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
25+
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
26+
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
27+
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
28+
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
29+
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
30+
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
31+
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
32+
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
33+
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
34+
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
35+
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
36+
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
37+
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
38+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
39+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
40+
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
41+
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
42+
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
743
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
844
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
945
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
46+
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
47+
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=

ui_tui.go

Lines changed: 195 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,199 @@
11
package main
22

3-
// runTUI is implemented in Task 11.
4-
func runTUI(listenAddr, upstream string, events <-chan Event) {
5-
// Fallback for now: just drain the channel silently.
6-
for range events {
3+
import (
4+
"fmt"
5+
"strings"
6+
"time"
7+
8+
tea "github.com/charmbracelet/bubbletea"
9+
"github.com/charmbracelet/lipgloss"
10+
)
11+
12+
const feedSize = 20
13+
14+
type tickMsg time.Time
15+
type eventMsg Event
16+
type doneMsg struct{}
17+
18+
type model struct {
19+
listenAddr string
20+
upstream string
21+
feed []Event
22+
total int
23+
errors int
24+
totalDur time.Duration
25+
startedAt time.Time
26+
tab int // 0 = requests, 1 = cert info
27+
}
28+
29+
func initialModel(listenAddr, upstream string) model {
30+
return model{
31+
listenAddr: listenAddr,
32+
upstream: upstream,
33+
startedAt: time.Now(),
34+
}
35+
}
36+
37+
func (m model) Init() tea.Cmd { return tea.Batch(tick(), tea.EnterAltScreen) }
38+
39+
func tick() tea.Cmd {
40+
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { return tickMsg(t) })
41+
}
42+
43+
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
44+
switch msg := msg.(type) {
45+
case tea.KeyMsg:
46+
switch msg.String() {
47+
case "q", "ctrl+c":
48+
return m, tea.Quit
49+
case "c":
50+
m.feed = nil
51+
case "1":
52+
m.tab = 0
53+
case "2":
54+
m.tab = 1
55+
}
56+
case tickMsg:
57+
return m, tick()
58+
case eventMsg:
59+
ev := Event(msg)
60+
m.total++
61+
m.totalDur += ev.Duration
62+
if ev.Status >= 400 {
63+
m.errors++
64+
}
65+
m.feed = append(m.feed, ev)
66+
if len(m.feed) > feedSize {
67+
m.feed = m.feed[len(m.feed)-feedSize:]
68+
}
69+
case doneMsg:
70+
return m, tea.Quit
71+
}
72+
return m, nil
73+
}
74+
75+
// Palette adapted from torlink (github.com/baairon/torlink).
76+
var (
77+
accent = lipgloss.Color("#a78bfa") // violet — active/focus/title
78+
muted = lipgloss.Color("#6b6577") // purple-grey — dividers, labels, inactive
79+
ok = lipgloss.Color("#86d6a2") // soft green — 2xx / good counts
80+
warn = lipgloss.Color("#f0c674") // 3xx
81+
bad = lipgloss.Color("#e57373") // 4xx/5xx / error counts
82+
83+
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(accent)
84+
labelStyle = lipgloss.NewStyle().Foreground(muted)
85+
dimStyle = lipgloss.NewStyle().Foreground(muted).Faint(true)
86+
sectionHdr = lipgloss.NewStyle().Foreground(accent).Bold(true)
87+
borderStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(muted).Padding(0, 1)
88+
statOK = lipgloss.NewStyle().Foreground(ok)
89+
statBad = lipgloss.NewStyle().Foreground(bad)
90+
statWarn = lipgloss.NewStyle().Foreground(warn)
91+
tabActive = lipgloss.NewStyle().Foreground(accent).Bold(true)
92+
tabInactive = lipgloss.NewStyle().Foreground(muted)
93+
chev = lipgloss.NewStyle().Foreground(accent).Render("❯")
94+
)
95+
96+
func (m model) View() string {
97+
var avg time.Duration
98+
if m.total > 0 {
99+
avg = m.totalDur / time.Duration(m.total)
100+
}
101+
uptime := time.Since(m.startedAt).Round(time.Second)
102+
103+
header := chev + " " + titleStyle.Render("httpsdev v"+Version) + " " +
104+
labelStyle.Render(m.listenAddr+" → "+m.upstream)
105+
106+
statsHdr := sectionHdr.Render("▲ live")
107+
statsBox := borderStyle.Render(fmt.Sprintf(
108+
"total %d errors %s avg %s uptime %s",
109+
m.total,
110+
colorInt(m.errors, m.errors == 0),
111+
avg.Round(time.Millisecond),
112+
uptime,
113+
))
114+
115+
var mainHdr, mainBox string
116+
switch m.tab {
117+
case 0:
118+
mainHdr = sectionHdr.Render("▲ requests")
119+
mainBox = borderStyle.Render(m.renderFeed())
120+
case 1:
121+
mainHdr = sectionHdr.Render("▲ cert")
122+
mainBox = borderStyle.Render(strings.Join([]string{
123+
"cert " + labelStyle.Render("mkcert-signed"),
124+
"subject " + labelStyle.Render("localhost"),
125+
"valid " + labelStyle.Render("30 days (rolling)"),
126+
"",
127+
dimStyle.Render("more introspection in v0.2"),
128+
}, "\n"))
129+
}
130+
131+
tabs := []string{"1 requests", "2 cert"}
132+
if m.tab == 0 {
133+
tabs[0] = tabActive.Render(tabs[0])
134+
tabs[1] = tabInactive.Render(tabs[1])
135+
} else {
136+
tabs[0] = tabInactive.Render(tabs[0])
137+
tabs[1] = tabActive.Render(tabs[1])
138+
}
139+
footer := strings.Join(tabs, " ") + labelStyle.Render(" q quit · c clear")
140+
141+
return strings.Join([]string{"", header, "", statsHdr, statsBox, "", mainHdr, mainBox, "", footer}, "\n")
142+
}
143+
144+
func (m model) renderFeed() string {
145+
if len(m.feed) == 0 {
146+
return dimStyle.Render("waiting for requests…")
147+
}
148+
var b strings.Builder
149+
lastIdx := len(m.feed) - 1
150+
for i, ev := range m.feed {
151+
s := statOK
152+
switch {
153+
case ev.Status >= 500:
154+
s = statBad
155+
case ev.Status >= 400:
156+
s = statBad
157+
case ev.Status >= 300:
158+
s = statWarn
159+
}
160+
marker := " "
161+
if i == lastIdx {
162+
marker = chev + " "
163+
}
164+
fmt.Fprintf(&b, "%s%-6s %-30s %s %s\n",
165+
marker,
166+
ev.Method,
167+
truncate(ev.Path, 30),
168+
s.Render(fmt.Sprintf("%d", ev.Status)),
169+
labelStyle.Render(ev.Duration.Round(time.Millisecond).String()),
170+
)
7171
}
8-
_ = listenAddr
9-
_ = upstream
172+
return strings.TrimRight(b.String(), "\n")
173+
}
174+
175+
func colorInt(n int, ok bool) string {
176+
if ok {
177+
return statOK.Render(fmt.Sprintf("%d", n))
178+
}
179+
return statBad.Render(fmt.Sprintf("%d", n))
180+
}
181+
182+
func truncate(s string, n int) string {
183+
if len(s) <= n {
184+
return s
185+
}
186+
return s[:n-1] + "…"
187+
}
188+
189+
// runTUI drives the bubbletea program, feeding it events from the channel.
190+
func runTUI(listenAddr, upstream string, events <-chan Event) {
191+
p := tea.NewProgram(initialModel(listenAddr, upstream))
192+
go func() {
193+
for ev := range events {
194+
p.Send(eventMsg(ev))
195+
}
196+
p.Send(doneMsg{})
197+
}()
198+
_, _ = p.Run()
10199
}

0 commit comments

Comments
 (0)