repos / pico

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

pico / pkg / tui
Antonio Mika  ·  2025-05-28

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) filterLogLine(match string, ll *LogLine) bool {
 50	if match == "" {
 51		return true
 52	}
 53
 54	lvlMatch := matched(ll.Level, match)
 55	msgMatch := matched(ll.Msg, match)
 56	serviceMatch := matched(ll.Service, match)
 57	errMatch := matched(ll.ErrMsg, match)
 58	urlMatch := matched(ll.Url, match)
 59	statusMatch := matched(fmt.Sprintf("%d", ll.Status), match)
 60
 61	if !lvlMatch && !msgMatch && !serviceMatch && !errMatch && !urlMatch && !statusMatch {
 62		return false
 63	}
 64
 65	return true
 66}
 67
 68func (m *LogsPage) filterLogs() {
 69	match := m.input.GetValue()
 70	filtered := []int{}
 71	for idx, ll := range m.logs {
 72		if m.filterLogLine(match, ll) {
 73			filtered = append(m.filtered, idx)
 74		}
 75	}
 76	m.filtered = filtered
 77
 78	// scroll to bottom
 79	if len(m.filtered) > 0 {
 80		m.list.SetCursor(uint(len(m.filtered) - 1))
 81	}
 82}
 83
 84func (m *LogsPage) CaptureEvent(ev vaxis.Event) (vxfw.Command, error) {
 85	switch ev.(type) {
 86	case vaxis.Key:
 87		m.filterLogs()
 88		return vxfw.RedrawCmd{}, nil
 89	}
 90	return nil, nil
 91}
 92
 93func (m *LogsPage) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
 94	switch msg := ev.(type) {
 95	case PageIn:
 96		go func() {
 97			_ = m.connectToLogs()
 98		}()
 99		return m.input.FocusIn()
100	case PageOut:
101		m.done()
102	case LogLineLoaded:
103		ll := NewLogLine(msg.Line)
104		m.logs = append(m.logs, ll)
105		m.filterLogs()
106		return vxfw.RedrawCmd{}, nil
107	}
108	return nil, nil
109}
110
111func (m *LogsPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
112	root := vxfw.NewSurface(ctx.Max.Width, ctx.Max.Height, m)
113
114	if len(m.logs) == 0 {
115		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.")
116		txtSurf, _ := txt.Draw(ctx)
117		root.AddChild(0, 0, txtSurf)
118	} else {
119		listSurf, _ := m.list.Draw(createDrawCtx(ctx, ctx.Max.Height-4))
120		root.AddChild(0, 0, listSurf)
121	}
122
123	inp, _ := m.input.Draw(createDrawCtx(ctx, 4))
124	root.AddChild(0, int(ctx.Max.Height)-3, inp)
125
126	return root, nil
127}
128
129func (m *LogsPage) getWidget(i uint, cursor uint) vxfw.Widget {
130	if len(m.filtered) == 0 {
131		return nil
132	}
133
134	if int(i) >= len(m.filtered) {
135		return nil
136	}
137
138	idx := m.filtered[i]
139	return logToWidget(m.logs[idx])
140}
141
142func (m *LogsPage) connectToLogs() error {
143	ctx, cancel := context.WithCancel(m.shared.Session.Context())
144	defer cancel()
145
146	m.ctx = ctx
147	m.done = cancel
148
149	conn := shared.NewPicoPipeClient()
150	drain, err := pipeLogger.ReadLogs(ctx, m.shared.Logger, conn)
151	if err != nil {
152		return err
153	}
154
155	scanner := bufio.NewScanner(drain)
156	scanner.Buffer(make([]byte, 32*1024), 32*1024)
157	for scanner.Scan() {
158		line := scanner.Text()
159		parsedData := map[string]any{}
160
161		err := json.Unmarshal([]byte(line), &parsedData)
162		if err != nil {
163			m.shared.Logger.Error("json unmarshal", "err", err, "line", line, "hidden", true)
164			continue
165		}
166
167		user := utils.AnyToStr(parsedData, "user")
168		userId := utils.AnyToStr(parsedData, "userId")
169
170		hidden := utils.AnyToBool(parsedData, "hidden")
171
172		if !hidden && (user == m.shared.User.Name || userId == m.shared.User.ID) {
173			m.shared.App.PostEvent(LogLineLoaded{parsedData})
174		}
175	}
176
177	if err := scanner.Err(); err != nil {
178		m.shared.Logger.Error("scanner error", "err", err)
179		return err
180	}
181
182	return nil
183}
184
185func matched(str, match string) bool {
186	prim := strings.ToLower(str)
187	mtch := strings.ToLower(match)
188	return strings.Contains(prim, mtch)
189}
190
191type LogLine struct {
192	Date    string
193	Service string
194	Level   string
195	Msg     string
196	ErrMsg  string
197	Status  int
198	Url     string
199}
200
201func NewLogLine(data map[string]any) *LogLine {
202	rawtime := utils.AnyToStr(data, "time")
203	service := utils.AnyToStr(data, "service")
204	level := utils.AnyToStr(data, "level")
205	msg := utils.AnyToStr(data, "msg")
206	errMsg := utils.AnyToStr(data, "err")
207	status := utils.AnyToFloat(data, "status")
208	url := utils.AnyToStr(data, "url")
209	date, err := time.Parse(time.RFC3339Nano, rawtime)
210	dateStr := rawtime
211	if err == nil {
212		dateStr = date.Format(time.RFC3339)
213	}
214
215	return &LogLine{
216		Date:    dateStr,
217		Service: service,
218		Level:   level,
219		Msg:     msg,
220		ErrMsg:  errMsg,
221		Status:  int(status),
222		Url:     url,
223	}
224}
225
226func logToWidget(ll *LogLine) vxfw.Widget {
227	segs := []vaxis.Segment{
228		{Text: ll.Date + " "},
229		{Text: ll.Service + " "},
230	}
231
232	if ll.Level == "ERROR" {
233		segs = append(segs, vaxis.Segment{Text: ll.Level, Style: vaxis.Style{Background: red}})
234	} else {
235		segs = append(segs, vaxis.Segment{Text: ll.Level})
236	}
237
238	segs = append(segs, vaxis.Segment{Text: " " + ll.Msg + " "})
239	if ll.ErrMsg != "" {
240		segs = append(segs, vaxis.Segment{Text: ll.ErrMsg + " ", Style: vaxis.Style{Foreground: red}})
241	}
242
243	if ll.Status > 0 {
244		if ll.Status >= 200 && ll.Status < 300 {
245			segs = append(segs, vaxis.Segment{
246				Text:  fmt.Sprintf("%d ", ll.Status),
247				Style: vaxis.Style{Foreground: green},
248			})
249		} else {
250			segs = append(segs,
251				vaxis.Segment{
252					Text:  fmt.Sprintf("%d ", ll.Status),
253					Style: vaxis.Style{Foreground: red},
254				})
255		}
256	}
257
258	segs = append(segs, vaxis.Segment{Text: ll.Url + " "})
259
260	txt := richtext.New(segs)
261	return txt
262}