Eric Bower
·
02 Dec 24
logs.go
1package logs
2
3import (
4 "bufio"
5 "context"
6 "encoding/json"
7 "fmt"
8 "strings"
9 "time"
10
11 input "github.com/charmbracelet/bubbles/textinput"
12 "github.com/charmbracelet/bubbles/viewport"
13 tea "github.com/charmbracelet/bubbletea"
14 "github.com/charmbracelet/lipgloss"
15 "github.com/picosh/pico/shared"
16 "github.com/picosh/pico/tui/common"
17 "github.com/picosh/pico/tui/pages"
18 "github.com/picosh/utils"
19 pipeLogger "github.com/picosh/utils/pipe/log"
20)
21
22type state int
23
24const (
25 stateLoading state = iota
26 stateReady
27)
28
29type logLineLoadedMsg map[string]any
30type errMsg error
31
32type Model struct {
33 shared *common.SharedModel
34 state state
35 logData []map[string]any
36 viewport viewport.Model
37 input input.Model
38 sub chan map[string]any
39 ctx context.Context
40 done context.CancelFunc
41 errMsg error
42}
43
44func headerHeight(shrd *common.SharedModel) int {
45 return shrd.HeaderHeight
46}
47
48func headerWidth(w int) int {
49 return w - 2
50}
51
52var defMsg = "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."
53
54func NewModel(shrd *common.SharedModel) Model {
55 im := input.New()
56 im.Cursor.Style = shrd.Styles.Cursor
57 im.Placeholder = "filter logs"
58 im.PlaceholderStyle = shrd.Styles.InputPlaceholder
59 im.Prompt = shrd.Styles.FocusedPrompt.String()
60 im.CharLimit = 50
61 im.Focus()
62
63 hh := headerHeight(shrd)
64 ww := headerWidth(shrd.Width)
65 inputHeight := lipgloss.Height(im.View())
66 viewport := viewport.New(ww, shrd.Height-hh-inputHeight)
67 viewport.YPosition = hh
68 viewport.SetContent(defMsg)
69
70 ctx, cancel := context.WithCancel(shrd.Session.Context())
71
72 return Model{
73 shared: shrd,
74 state: stateLoading,
75 viewport: viewport,
76 logData: []map[string]any{},
77 input: im,
78 sub: make(chan map[string]any),
79 ctx: ctx,
80 done: cancel,
81 }
82}
83
84func (m Model) Init() tea.Cmd {
85 return tea.Batch(
86 m.connectLogs(m.sub),
87 m.waitForActivity(m.sub),
88 )
89}
90
91func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
92 var cmds []tea.Cmd
93 var cmd tea.Cmd
94 switch msg := msg.(type) {
95 case tea.WindowSizeMsg:
96 m.viewport.Width = headerWidth(msg.Width)
97 inputHeight := lipgloss.Height(m.input.View())
98 hh := headerHeight(m.shared)
99 m.viewport.Height = msg.Height - hh - inputHeight
100 m.viewport.SetContent(logsToStr(m.shared.Styles, m.logData, m.input.Value()))
101
102 case logLineLoadedMsg:
103 m.state = stateReady
104 m.logData = append(m.logData, msg)
105 lng := len(m.logData)
106 if lng > 1000 {
107 m.logData = m.logData[lng-1000:]
108 }
109 wasAt := false
110 if m.viewport.AtBottom() {
111 wasAt = true
112 }
113 m.viewport.SetContent(logsToStr(m.shared.Styles, m.logData, m.input.Value()))
114 if wasAt {
115 m.viewport.GotoBottom()
116 }
117 cmds = append(cmds, m.waitForActivity(m.sub))
118
119 case errMsg:
120 m.errMsg = msg
121
122 case pages.NavigateMsg:
123 // cancel activity logger
124 m.done()
125 // reset model
126 next := NewModel(m.shared)
127 return next, nil
128
129 case tea.KeyMsg:
130 switch msg.String() {
131 case "q", "esc":
132 return m, pages.Navigate(pages.MenuPage)
133 case "tab":
134 if m.input.Focused() {
135 m.input.Blur()
136 } else {
137 cmds = append(cmds, m.input.Focus())
138 }
139 default:
140 m.viewport.SetContent(logsToStr(m.shared.Styles, m.logData, m.input.Value()))
141 m.viewport.GotoBottom()
142 }
143 }
144 m.input, cmd = m.input.Update(msg)
145 cmds = append(cmds, cmd)
146 m.viewport, cmd = m.viewport.Update(msg)
147 cmds = append(cmds, cmd)
148 return m, tea.Batch(cmds...)
149}
150
151func (m Model) View() string {
152 if m.errMsg != nil {
153 return m.shared.Styles.Error.Render(m.errMsg.Error())
154 }
155 if m.state == stateLoading {
156 return defMsg
157 }
158 return m.viewport.View() + "\n" + m.input.View()
159}
160
161func (m Model) waitForActivity(sub chan map[string]any) tea.Cmd {
162 return func() tea.Msg {
163 select {
164 case result := <-sub:
165 return logLineLoadedMsg(result)
166 case <-m.ctx.Done():
167 return nil
168 }
169 }
170}
171
172func (m Model) connectLogs(sub chan map[string]any) tea.Cmd {
173 return func() tea.Msg {
174 conn := shared.NewPicoPipeClient()
175 drain, err := pipeLogger.ReadLogs(m.ctx, m.shared.Logger, conn)
176 if err != nil {
177 return errMsg(err)
178 }
179
180 scanner := bufio.NewScanner(drain)
181 for scanner.Scan() {
182 line := scanner.Text()
183 parsedData := map[string]any{}
184
185 err := json.Unmarshal([]byte(line), &parsedData)
186 if err != nil {
187 m.shared.Logger.Error("json unmarshal", "err", err, "line", line)
188 continue
189 }
190
191 user := utils.AnyToStr(parsedData, "user")
192 userId := utils.AnyToStr(parsedData, "userId")
193 if user == m.shared.User.Name || userId == m.shared.User.ID {
194 sub <- parsedData
195 }
196 }
197
198 return nil
199 }
200}
201
202func matched(str, match string) bool {
203 prim := strings.ToLower(str)
204 mtch := strings.ToLower(match)
205 return strings.Contains(prim, mtch)
206}
207
208func logToStr(styles common.Styles, data map[string]any, match string) string {
209 rawtime := utils.AnyToStr(data, "time")
210 service := utils.AnyToStr(data, "service")
211 level := utils.AnyToStr(data, "level")
212 msg := utils.AnyToStr(data, "msg")
213 errMsg := utils.AnyToStr(data, "err")
214 status := utils.AnyToFloat(data, "status")
215 url := utils.AnyToStr(data, "url")
216
217 if match != "" {
218 lvlMatch := matched(level, match)
219 msgMatch := matched(msg, match)
220 serviceMatch := matched(service, match)
221 errMatch := matched(errMsg, match)
222 urlMatch := matched(url, match)
223 statusMatch := matched(fmt.Sprintf("%d", int(status)), match)
224 if !lvlMatch && !msgMatch && !serviceMatch && !errMatch && !urlMatch && !statusMatch {
225 return ""
226 }
227 }
228
229 date, err := time.Parse(time.RFC3339Nano, rawtime)
230 dateStr := rawtime
231 if err == nil {
232 dateStr = date.Format(time.RFC3339)
233 }
234
235 acc := fmt.Sprintf(
236 "%s %s %s %s %s %s %s",
237 dateStr,
238 service,
239 levelView(styles, level),
240 msg,
241 styles.Error.Render(errMsg),
242 statusView(styles, int(status)),
243 url,
244 )
245 return acc
246}
247
248func statusView(styles common.Styles, status int) string {
249 statusStr := fmt.Sprintf("%d", status)
250 if status >= 200 && status < 300 {
251 return statusStr
252 }
253 return styles.Error.Render(statusStr)
254}
255
256func levelView(styles common.Styles, level string) string {
257 if level == "ERROR" {
258 return styles.Error.Render(level)
259 }
260 return styles.Note.Render(level)
261}
262
263func logsToStr(styles common.Styles, data []map[string]any, filter string) string {
264 acc := ""
265 for _, d := range data {
266 res := logToStr(styles, d, filter)
267 if res != "" {
268 acc += res
269 acc += "\n"
270 }
271 }
272
273 if acc == "" {
274 if filter == "" {
275 return defMsg
276 } else {
277 return "No results found for filter provided."
278 }
279 }
280
281 return acc
282}