Antonio Mika
·
17 Nov 24
logs.go
1package logs
2
3import (
4 "bufio"
5 "context"
6 "encoding/json"
7 "fmt"
8 "strings"
9
10 input "github.com/charmbracelet/bubbles/textinput"
11 "github.com/charmbracelet/bubbles/viewport"
12 tea "github.com/charmbracelet/bubbletea"
13 "github.com/charmbracelet/lipgloss"
14 "github.com/picosh/pico/shared"
15 "github.com/picosh/pico/tui/common"
16 "github.com/picosh/pico/tui/pages"
17 "github.com/picosh/utils"
18
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 = "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 stdoutPipe, err := pipeLogger.ReadLogs(m.ctx, m.shared.Logger, conn)
176 if err != nil {
177 return errMsg(err)
178 }
179
180 scanner := bufio.NewScanner(stdoutPipe)
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)
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 time := 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 acc := fmt.Sprintf(
230 "%s %s %s %s %s %s %s",
231 time,
232 service,
233 levelView(styles, level),
234 msg,
235 styles.Error.Render(errMsg),
236 statusView(styles, int(status)),
237 url,
238 )
239 return acc
240}
241
242func statusView(styles common.Styles, status int) string {
243 statusStr := fmt.Sprintf("%d", status)
244 if status >= 200 && status < 300 {
245 return statusStr
246 }
247 return styles.Error.Render(statusStr)
248}
249
250func levelView(styles common.Styles, level string) string {
251 if level == "ERROR" {
252 return styles.Error.Render(level)
253 }
254 return styles.Note.Render(level)
255}
256
257func logsToStr(styles common.Styles, data []map[string]any, filter string) string {
258 acc := ""
259 for _, d := range data {
260 res := logToStr(styles, d, filter)
261 if res != "" {
262 acc += res
263 acc += "\n"
264 }
265 }
266
267 if acc == "" {
268 if filter == "" {
269 return defMsg
270 } else {
271 return "No results found for filter provided."
272 }
273 }
274
275 return acc
276}