Eric Bower
·
2026-01-25
logs.go
1package tui
2
3import (
4 "bufio"
5 "context"
6 "encoding/json"
7 "fmt"
8 "strings"
9 "time"
10
11 "git.sr.ht/~rockorager/vaxis"
12 "git.sr.ht/~rockorager/vaxis/vxfw"
13 "git.sr.ht/~rockorager/vaxis/vxfw/list"
14 "git.sr.ht/~rockorager/vaxis/vxfw/richtext"
15 "git.sr.ht/~rockorager/vaxis/vxfw/text"
16 "github.com/picosh/pico/pkg/shared"
17 pipeLogger "github.com/picosh/utils/pipe/log"
18)
19
20type LogLineLoaded struct {
21 Line map[string]any
22}
23
24type LogsPage struct {
25 shared *SharedModel
26
27 input *TextInput
28 list *list.Dynamic
29 filtered []int
30 logs []*LogLine
31 ctx context.Context
32 done context.CancelFunc
33 focus string
34}
35
36func NewLogsPage(shrd *SharedModel) *LogsPage {
37 page := &LogsPage{
38 shared: shrd,
39 input: NewTextInput("filter logs"),
40 }
41 page.list = &list.Dynamic{Builder: page.getWidget, DrawCursor: true}
42 return page
43}
44
45func (m *LogsPage) Footer() []Shortcut {
46 return []Shortcut{
47 {Shortcut: "tab", Text: "toggle focus"},
48 {Shortcut: "↑↓", Text: "scroll"},
49 {Shortcut: "G", Text: "bottom"},
50 }
51}
52
53func (m *LogsPage) filterLogLine(match string, ll *LogLine) bool {
54 if match == "" {
55 return true
56 }
57
58 lvlMatch := matched(ll.Level, match)
59 msgMatch := matched(ll.Msg, match)
60 serviceMatch := matched(ll.Service, match)
61 errMatch := matched(ll.ErrMsg, match)
62 urlMatch := matched(ll.Url, match)
63 statusMatch := matched(fmt.Sprintf("%d", ll.Status), match)
64
65 if !lvlMatch && !msgMatch && !serviceMatch && !errMatch && !urlMatch && !statusMatch {
66 return false
67 }
68
69 return true
70}
71
72func (m *LogsPage) filterLogs() {
73 match := m.input.GetValue()
74 filtered := []int{}
75 for idx, ll := range m.logs {
76 if m.filterLogLine(match, ll) {
77 filtered = append(filtered, idx)
78 }
79 }
80
81 // Check if cursor is at the last position before filtering
82 isAtBottom := len(m.filtered) == 0 || int(m.list.Cursor()) == len(m.filtered)-1
83
84 m.filtered = filtered
85
86 // scroll to bottom only if we were already at the bottom
87 if isAtBottom && len(m.filtered) > 0 {
88 m.list.SetCursor(uint(len(m.filtered) - 1))
89 }
90}
91
92func (m *LogsPage) CaptureEvent(ev vaxis.Event) (vxfw.Command, error) {
93 switch msg := ev.(type) {
94 case vaxis.Key:
95 if msg.Matches(vaxis.KeyTab) {
96 return nil, nil
97 }
98 if m.focus == "input" {
99 m.filterLogs()
100 return vxfw.RedrawCmd{}, nil
101 }
102 }
103 return nil, nil
104}
105
106func (m *LogsPage) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
107 switch msg := ev.(type) {
108 case PageIn:
109 go func() {
110 _ = m.connectToLogs()
111 }()
112 m.focus = "input"
113 return m.input.FocusIn()
114 case PageOut:
115 m.done()
116 case vaxis.Key:
117 if msg.Matches(vaxis.KeyTab) {
118 if m.focus == "input" {
119 m.focus = "list"
120 m.filterLogs()
121 cmd, _ := m.input.FocusOut()
122 return vxfw.BatchCmd([]vxfw.Command{
123 vxfw.FocusWidgetCmd(m.list),
124 cmd,
125 }), nil
126 }
127 m.focus = "input"
128 cmd, _ := m.input.FocusIn()
129 return vxfw.BatchCmd([]vxfw.Command{
130 cmd,
131 vxfw.RedrawCmd{},
132 }), nil
133 }
134 if msg.Matches('G') {
135 // Scroll to bottom
136 if len(m.filtered) > 0 {
137 m.list.SetCursor(uint(len(m.filtered) - 1))
138 return vxfw.RedrawCmd{}, nil
139 }
140 }
141 case LogLineLoaded:
142 ll := NewLogLine(msg.Line)
143 m.logs = append(m.logs, ll)
144 m.filterLogs()
145 return vxfw.RedrawCmd{}, nil
146 }
147 return nil, nil
148}
149
150func (m *LogsPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
151 root := vxfw.NewSurface(ctx.Max.Width, ctx.Max.Height, m)
152
153 if len(m.logs) == 0 {
154 txt := text.New("This view shows all logs generated by our services tagged with your user. This view will show errors triggered by your pages sites, blogs, tuns, etc. Logs will show up here in realtime as they are generated. There is no log buffer.")
155 txtSurf, _ := txt.Draw(ctx)
156 root.AddChild(0, 0, txtSurf)
157 } else {
158 listPane := NewBorder(m.list)
159 listPane.Label = "logs"
160 m.focusBorder(listPane)
161 listSurf, _ := listPane.Draw(createDrawCtx(ctx, ctx.Max.Height-4))
162 root.AddChild(0, 0, listSurf)
163 }
164
165 inp, _ := m.input.Draw(createDrawCtx(ctx, 4))
166 root.AddChild(0, int(ctx.Max.Height)-3, inp)
167
168 return root, nil
169}
170
171func (m *LogsPage) focusBorder(border *Border) {
172 if m.focus == "list" {
173 border.Style = vaxis.Style{Foreground: oj}
174 } else {
175 border.Style = vaxis.Style{Foreground: purp}
176 }
177}
178
179func (m *LogsPage) getWidget(i uint, cursor uint) vxfw.Widget {
180 if len(m.filtered) == 0 {
181 return nil
182 }
183
184 if int(i) >= len(m.filtered) {
185 return nil
186 }
187
188 idx := m.filtered[i]
189 return logToWidget(m.logs[idx])
190}
191
192func (m *LogsPage) connectToLogs() error {
193 ctx, cancel := context.WithCancel(m.shared.Session.Context())
194 defer cancel()
195
196 m.ctx = ctx
197 m.done = cancel
198
199 conn := shared.NewPicoPipeClient()
200 drain, err := pipeLogger.ReadLogs(ctx, m.shared.Logger, conn)
201 if err != nil {
202 return err
203 }
204
205 scanner := bufio.NewScanner(drain)
206 scanner.Buffer(make([]byte, 32*1024), 32*1024)
207 for scanner.Scan() {
208 line := scanner.Text()
209 parsedData := map[string]any{}
210
211 err := json.Unmarshal([]byte(line), &parsedData)
212 if err != nil {
213 m.shared.Logger.Error("json unmarshal", "err", err, "line", line, "hidden", true)
214 continue
215 }
216
217 user := shared.AnyToStr(parsedData, "user")
218 userId := shared.AnyToStr(parsedData, "userId")
219
220 hidden := shared.AnyToBool(parsedData, "hidden")
221
222 if !hidden && (user == m.shared.User.Name || userId == m.shared.User.ID) {
223 m.shared.App.PostEvent(LogLineLoaded{parsedData})
224 }
225 }
226
227 if err := scanner.Err(); err != nil {
228 m.shared.Logger.Error("scanner error", "err", err)
229 return err
230 }
231
232 return nil
233}
234
235func matched(str, match string) bool {
236 prim := strings.ToLower(str)
237 mtch := strings.ToLower(match)
238 return strings.Contains(prim, mtch)
239}
240
241type LogLine struct {
242 Date string
243 Service string
244 Level string
245 Msg string
246 ErrMsg string
247 Status int
248 Url string
249}
250
251func NewLogLine(data map[string]any) *LogLine {
252 rawtime := shared.AnyToStr(data, "time")
253 service := shared.AnyToStr(data, "service")
254 level := shared.AnyToStr(data, "level")
255 msg := shared.AnyToStr(data, "msg")
256 errMsg := shared.AnyToStr(data, "err")
257 status := shared.AnyToFloat(data, "status")
258 url := shared.AnyToStr(data, "url")
259 date, err := time.Parse(time.RFC3339Nano, rawtime)
260 dateStr := rawtime
261 if err == nil {
262 dateStr = date.Format(time.RFC3339)
263 }
264
265 return &LogLine{
266 Date: dateStr,
267 Service: service,
268 Level: level,
269 Msg: msg,
270 ErrMsg: errMsg,
271 Status: int(status),
272 Url: url,
273 }
274}
275
276func logToWidget(ll *LogLine) vxfw.Widget {
277 segs := []vaxis.Segment{
278 {Text: ll.Date + " "},
279 {Text: ll.Service + " "},
280 }
281
282 if ll.Level == "ERROR" {
283 segs = append(segs, vaxis.Segment{Text: ll.Level, Style: vaxis.Style{Background: red}})
284 } else {
285 segs = append(segs, vaxis.Segment{Text: ll.Level})
286 }
287
288 segs = append(segs, vaxis.Segment{Text: " " + ll.Msg + " "})
289 if ll.ErrMsg != "" {
290 segs = append(segs, vaxis.Segment{Text: ll.ErrMsg + " ", Style: vaxis.Style{Foreground: red}})
291 }
292
293 if ll.Status > 0 {
294 if ll.Status >= 200 && ll.Status < 300 {
295 segs = append(segs, vaxis.Segment{
296 Text: fmt.Sprintf("%d ", ll.Status),
297 Style: vaxis.Style{Foreground: green},
298 })
299 } else {
300 segs = append(segs,
301 vaxis.Segment{
302 Text: fmt.Sprintf("%d ", ll.Status),
303 Style: vaxis.Style{Foreground: red},
304 })
305 }
306 }
307
308 segs = append(segs, vaxis.Segment{Text: ll.Url + " "})
309
310 txt := richtext.New(segs)
311 return txt
312}