repos / pico

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

pico / pkg / tui
Eric Bower  ·  2026-01-25

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	pipeLogger "github.com/picosh/utils/pipe/log"
 18)
 19
 20type LogLineLoaded struct {
 21	Line map[string]any
 22}
 23
 24type LogsPage struct {
 25	shared *SharedModel
 26
 27	input    *TextInput
 28	list     *list.Dynamic
 29	filtered []int
 30	logs     []*LogLine
 31	ctx      context.Context
 32	done     context.CancelFunc
 33	focus    string
 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, DrawCursor: true}
 42	return page
 43}
 44
 45func (m *LogsPage) Footer() []Shortcut {
 46	return []Shortcut{
 47		{Shortcut: "tab", Text: "toggle focus"},
 48		{Shortcut: "↑↓", Text: "scroll"},
 49		{Shortcut: "G", Text: "bottom"},
 50	}
 51}
 52
 53func (m *LogsPage) filterLogLine(match string, ll *LogLine) bool {
 54	if match == "" {
 55		return true
 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
 65	if !lvlMatch && !msgMatch && !serviceMatch && !errMatch && !urlMatch && !statusMatch {
 66		return false
 67	}
 68
 69	return true
 70}
 71
 72func (m *LogsPage) filterLogs() {
 73	match := m.input.GetValue()
 74	filtered := []int{}
 75	for idx, ll := range m.logs {
 76		if m.filterLogLine(match, ll) {
 77			filtered = append(filtered, idx)
 78		}
 79	}
 80
 81	// Check if cursor is at the last position before filtering
 82	isAtBottom := len(m.filtered) == 0 || int(m.list.Cursor()) == len(m.filtered)-1
 83
 84	m.filtered = filtered
 85
 86	// scroll to bottom only if we were already at the bottom
 87	if isAtBottom && len(m.filtered) > 0 {
 88		m.list.SetCursor(uint(len(m.filtered) - 1))
 89	}
 90}
 91
 92func (m *LogsPage) CaptureEvent(ev vaxis.Event) (vxfw.Command, error) {
 93	switch msg := ev.(type) {
 94	case vaxis.Key:
 95		if msg.Matches(vaxis.KeyTab) {
 96			return nil, nil
 97		}
 98		if m.focus == "input" {
 99			m.filterLogs()
100			return vxfw.RedrawCmd{}, nil
101		}
102	}
103	return nil, nil
104}
105
106func (m *LogsPage) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
107	switch msg := ev.(type) {
108	case PageIn:
109		go func() {
110			_ = m.connectToLogs()
111		}()
112		m.focus = "input"
113		return m.input.FocusIn()
114	case PageOut:
115		m.done()
116	case vaxis.Key:
117		if msg.Matches(vaxis.KeyTab) {
118			if m.focus == "input" {
119				m.focus = "list"
120				m.filterLogs()
121				cmd, _ := m.input.FocusOut()
122				return vxfw.BatchCmd([]vxfw.Command{
123					vxfw.FocusWidgetCmd(m.list),
124					cmd,
125				}), nil
126			}
127			m.focus = "input"
128			cmd, _ := m.input.FocusIn()
129			return vxfw.BatchCmd([]vxfw.Command{
130				cmd,
131				vxfw.RedrawCmd{},
132			}), nil
133		}
134		if msg.Matches('G') {
135			// Scroll to bottom
136			if len(m.filtered) > 0 {
137				m.list.SetCursor(uint(len(m.filtered) - 1))
138				return vxfw.RedrawCmd{}, nil
139			}
140		}
141	case LogLineLoaded:
142		ll := NewLogLine(msg.Line)
143		m.logs = append(m.logs, ll)
144		m.filterLogs()
145		return vxfw.RedrawCmd{}, nil
146	}
147	return nil, nil
148}
149
150func (m *LogsPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
151	root := vxfw.NewSurface(ctx.Max.Width, ctx.Max.Height, m)
152
153	if len(m.logs) == 0 {
154		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.")
155		txtSurf, _ := txt.Draw(ctx)
156		root.AddChild(0, 0, txtSurf)
157	} else {
158		listPane := NewBorder(m.list)
159		listPane.Label = "logs"
160		m.focusBorder(listPane)
161		listSurf, _ := listPane.Draw(createDrawCtx(ctx, ctx.Max.Height-4))
162		root.AddChild(0, 0, listSurf)
163	}
164
165	inp, _ := m.input.Draw(createDrawCtx(ctx, 4))
166	root.AddChild(0, int(ctx.Max.Height)-3, inp)
167
168	return root, nil
169}
170
171func (m *LogsPage) focusBorder(border *Border) {
172	if m.focus == "list" {
173		border.Style = vaxis.Style{Foreground: oj}
174	} else {
175		border.Style = vaxis.Style{Foreground: purp}
176	}
177}
178
179func (m *LogsPage) getWidget(i uint, cursor uint) vxfw.Widget {
180	if len(m.filtered) == 0 {
181		return nil
182	}
183
184	if int(i) >= len(m.filtered) {
185		return nil
186	}
187
188	idx := m.filtered[i]
189	return logToWidget(m.logs[idx])
190}
191
192func (m *LogsPage) connectToLogs() error {
193	ctx, cancel := context.WithCancel(m.shared.Session.Context())
194	defer cancel()
195
196	m.ctx = ctx
197	m.done = cancel
198
199	conn := shared.NewPicoPipeClient()
200	drain, err := pipeLogger.ReadLogs(ctx, m.shared.Logger, conn)
201	if err != nil {
202		return err
203	}
204
205	scanner := bufio.NewScanner(drain)
206	scanner.Buffer(make([]byte, 32*1024), 32*1024)
207	for scanner.Scan() {
208		line := scanner.Text()
209		parsedData := map[string]any{}
210
211		err := json.Unmarshal([]byte(line), &parsedData)
212		if err != nil {
213			m.shared.Logger.Error("json unmarshal", "err", err, "line", line, "hidden", true)
214			continue
215		}
216
217		user := shared.AnyToStr(parsedData, "user")
218		userId := shared.AnyToStr(parsedData, "userId")
219
220		hidden := shared.AnyToBool(parsedData, "hidden")
221
222		if !hidden && (user == m.shared.User.Name || userId == m.shared.User.ID) {
223			m.shared.App.PostEvent(LogLineLoaded{parsedData})
224		}
225	}
226
227	if err := scanner.Err(); err != nil {
228		m.shared.Logger.Error("scanner error", "err", err)
229		return err
230	}
231
232	return nil
233}
234
235func matched(str, match string) bool {
236	prim := strings.ToLower(str)
237	mtch := strings.ToLower(match)
238	return strings.Contains(prim, mtch)
239}
240
241type LogLine struct {
242	Date    string
243	Service string
244	Level   string
245	Msg     string
246	ErrMsg  string
247	Status  int
248	Url     string
249}
250
251func NewLogLine(data map[string]any) *LogLine {
252	rawtime := shared.AnyToStr(data, "time")
253	service := shared.AnyToStr(data, "service")
254	level := shared.AnyToStr(data, "level")
255	msg := shared.AnyToStr(data, "msg")
256	errMsg := shared.AnyToStr(data, "err")
257	status := shared.AnyToFloat(data, "status")
258	url := shared.AnyToStr(data, "url")
259	date, err := time.Parse(time.RFC3339Nano, rawtime)
260	dateStr := rawtime
261	if err == nil {
262		dateStr = date.Format(time.RFC3339)
263	}
264
265	return &LogLine{
266		Date:    dateStr,
267		Service: service,
268		Level:   level,
269		Msg:     msg,
270		ErrMsg:  errMsg,
271		Status:  int(status),
272		Url:     url,
273	}
274}
275
276func logToWidget(ll *LogLine) vxfw.Widget {
277	segs := []vaxis.Segment{
278		{Text: ll.Date + " "},
279		{Text: ll.Service + " "},
280	}
281
282	if ll.Level == "ERROR" {
283		segs = append(segs, vaxis.Segment{Text: ll.Level, Style: vaxis.Style{Background: red}})
284	} else {
285		segs = append(segs, vaxis.Segment{Text: ll.Level})
286	}
287
288	segs = append(segs, vaxis.Segment{Text: " " + ll.Msg + " "})
289	if ll.ErrMsg != "" {
290		segs = append(segs, vaxis.Segment{Text: ll.ErrMsg + " ", Style: vaxis.Style{Foreground: red}})
291	}
292
293	if ll.Status > 0 {
294		if ll.Status >= 200 && ll.Status < 300 {
295			segs = append(segs, vaxis.Segment{
296				Text:  fmt.Sprintf("%d ", ll.Status),
297				Style: vaxis.Style{Foreground: green},
298			})
299		} else {
300			segs = append(segs,
301				vaxis.Segment{
302					Text:  fmt.Sprintf("%d ", ll.Status),
303					Style: vaxis.Style{Foreground: red},
304				})
305		}
306	}
307
308	segs = append(segs, vaxis.Segment{Text: ll.Url + " "})
309
310	txt := richtext.New(segs)
311	return txt
312}