repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

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