Antonio Mika
·
2025-05-28
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 "github.com/picosh/utils"
18 pipeLogger "github.com/picosh/utils/pipe/log"
19)
20
21type LogLineLoaded struct {
22 Line map[string]any
23}
24
25type LogsPage struct {
26 shared *SharedModel
27
28 input *TextInput
29 list *list.Dynamic
30 filtered []int
31 logs []*LogLine
32 ctx context.Context
33 done context.CancelFunc
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, DisableEventHandlers: true}
42 return page
43}
44
45func (m *LogsPage) Footer() []Shortcut {
46 return []Shortcut{}
47}
48
49func (m *LogsPage) filterLogLine(match string, ll *LogLine) bool {
50 if match == "" {
51 return true
52 }
53
54 lvlMatch := matched(ll.Level, match)
55 msgMatch := matched(ll.Msg, match)
56 serviceMatch := matched(ll.Service, match)
57 errMatch := matched(ll.ErrMsg, match)
58 urlMatch := matched(ll.Url, match)
59 statusMatch := matched(fmt.Sprintf("%d", ll.Status), match)
60
61 if !lvlMatch && !msgMatch && !serviceMatch && !errMatch && !urlMatch && !statusMatch {
62 return false
63 }
64
65 return true
66}
67
68func (m *LogsPage) filterLogs() {
69 match := m.input.GetValue()
70 filtered := []int{}
71 for idx, ll := range m.logs {
72 if m.filterLogLine(match, ll) {
73 filtered = append(m.filtered, idx)
74 }
75 }
76 m.filtered = filtered
77
78 // scroll to bottom
79 if len(m.filtered) > 0 {
80 m.list.SetCursor(uint(len(m.filtered) - 1))
81 }
82}
83
84func (m *LogsPage) CaptureEvent(ev vaxis.Event) (vxfw.Command, error) {
85 switch ev.(type) {
86 case vaxis.Key:
87 m.filterLogs()
88 return vxfw.RedrawCmd{}, nil
89 }
90 return nil, nil
91}
92
93func (m *LogsPage) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
94 switch msg := ev.(type) {
95 case PageIn:
96 go func() {
97 _ = m.connectToLogs()
98 }()
99 return m.input.FocusIn()
100 case PageOut:
101 m.done()
102 case LogLineLoaded:
103 ll := NewLogLine(msg.Line)
104 m.logs = append(m.logs, ll)
105 m.filterLogs()
106 return vxfw.RedrawCmd{}, nil
107 }
108 return nil, nil
109}
110
111func (m *LogsPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
112 root := vxfw.NewSurface(ctx.Max.Width, ctx.Max.Height, m)
113
114 if len(m.logs) == 0 {
115 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.")
116 txtSurf, _ := txt.Draw(ctx)
117 root.AddChild(0, 0, txtSurf)
118 } else {
119 listSurf, _ := m.list.Draw(createDrawCtx(ctx, ctx.Max.Height-4))
120 root.AddChild(0, 0, listSurf)
121 }
122
123 inp, _ := m.input.Draw(createDrawCtx(ctx, 4))
124 root.AddChild(0, int(ctx.Max.Height)-3, inp)
125
126 return root, nil
127}
128
129func (m *LogsPage) getWidget(i uint, cursor uint) vxfw.Widget {
130 if len(m.filtered) == 0 {
131 return nil
132 }
133
134 if int(i) >= len(m.filtered) {
135 return nil
136 }
137
138 idx := m.filtered[i]
139 return logToWidget(m.logs[idx])
140}
141
142func (m *LogsPage) connectToLogs() error {
143 ctx, cancel := context.WithCancel(m.shared.Session.Context())
144 defer cancel()
145
146 m.ctx = ctx
147 m.done = cancel
148
149 conn := shared.NewPicoPipeClient()
150 drain, err := pipeLogger.ReadLogs(ctx, m.shared.Logger, conn)
151 if err != nil {
152 return err
153 }
154
155 scanner := bufio.NewScanner(drain)
156 scanner.Buffer(make([]byte, 32*1024), 32*1024)
157 for scanner.Scan() {
158 line := scanner.Text()
159 parsedData := map[string]any{}
160
161 err := json.Unmarshal([]byte(line), &parsedData)
162 if err != nil {
163 m.shared.Logger.Error("json unmarshal", "err", err, "line", line, "hidden", true)
164 continue
165 }
166
167 user := utils.AnyToStr(parsedData, "user")
168 userId := utils.AnyToStr(parsedData, "userId")
169
170 hidden := utils.AnyToBool(parsedData, "hidden")
171
172 if !hidden && (user == m.shared.User.Name || userId == m.shared.User.ID) {
173 m.shared.App.PostEvent(LogLineLoaded{parsedData})
174 }
175 }
176
177 if err := scanner.Err(); err != nil {
178 m.shared.Logger.Error("scanner error", "err", err)
179 return err
180 }
181
182 return nil
183}
184
185func matched(str, match string) bool {
186 prim := strings.ToLower(str)
187 mtch := strings.ToLower(match)
188 return strings.Contains(prim, mtch)
189}
190
191type LogLine struct {
192 Date string
193 Service string
194 Level string
195 Msg string
196 ErrMsg string
197 Status int
198 Url string
199}
200
201func NewLogLine(data map[string]any) *LogLine {
202 rawtime := utils.AnyToStr(data, "time")
203 service := utils.AnyToStr(data, "service")
204 level := utils.AnyToStr(data, "level")
205 msg := utils.AnyToStr(data, "msg")
206 errMsg := utils.AnyToStr(data, "err")
207 status := utils.AnyToFloat(data, "status")
208 url := utils.AnyToStr(data, "url")
209 date, err := time.Parse(time.RFC3339Nano, rawtime)
210 dateStr := rawtime
211 if err == nil {
212 dateStr = date.Format(time.RFC3339)
213 }
214
215 return &LogLine{
216 Date: dateStr,
217 Service: service,
218 Level: level,
219 Msg: msg,
220 ErrMsg: errMsg,
221 Status: int(status),
222 Url: url,
223 }
224}
225
226func logToWidget(ll *LogLine) vxfw.Widget {
227 segs := []vaxis.Segment{
228 {Text: ll.Date + " "},
229 {Text: ll.Service + " "},
230 }
231
232 if ll.Level == "ERROR" {
233 segs = append(segs, vaxis.Segment{Text: ll.Level, Style: vaxis.Style{Background: red}})
234 } else {
235 segs = append(segs, vaxis.Segment{Text: ll.Level})
236 }
237
238 segs = append(segs, vaxis.Segment{Text: " " + ll.Msg + " "})
239 if ll.ErrMsg != "" {
240 segs = append(segs, vaxis.Segment{Text: ll.ErrMsg + " ", Style: vaxis.Style{Foreground: red}})
241 }
242
243 if ll.Status > 0 {
244 if ll.Status >= 200 && ll.Status < 300 {
245 segs = append(segs, vaxis.Segment{
246 Text: fmt.Sprintf("%d ", ll.Status),
247 Style: vaxis.Style{Foreground: green},
248 })
249 } else {
250 segs = append(segs,
251 vaxis.Segment{
252 Text: fmt.Sprintf("%d ", ll.Status),
253 Style: vaxis.Style{Foreground: red},
254 })
255 }
256 }
257
258 segs = append(segs, vaxis.Segment{Text: ll.Url + " "})
259
260 txt := richtext.New(segs)
261 return txt
262}