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