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
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}