repos / pico

pico services - prose.sh, pastes.sh, imgs.sh, feeds.sh, pgs.sh
git clone https://github.com/picosh/pico.git

pico / tui / logs
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}