repos / pico

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

commit
43cf6b2
parent
aaa58cb
author
Eric Bower
date
2025-12-18 15:31:14 -0500 EST
feat(tui): access logs
5 files changed,  +262, -20
M pkg/db/postgres/storage.go
+2, -2
 1@@ -1479,7 +1479,7 @@ func (me *PsqlDB) FindUserStats(userID string) (*db.UserStats, error) {
 2 
 3 func (me *PsqlDB) FindAccessLogs(userID string, fromDate *time.Time) ([]*db.AccessLog, error) {
 4 	var logs []*db.AccessLog
 5-	err := me.Db.Select(&logs, `SELECT * FROM access_logs WHERE user_id=$1 AND created_at >= $2 ORDER BY created_at DESC`, userID, fromDate)
 6+	err := me.Db.Select(&logs, `SELECT * FROM access_logs WHERE user_id=$1 AND created_at >= $2 ORDER BY created_at ASC`, userID, fromDate)
 7 	if err != nil {
 8 		return nil, err
 9 	}
10@@ -1488,7 +1488,7 @@ func (me *PsqlDB) FindAccessLogs(userID string, fromDate *time.Time) ([]*db.Acce
11 
12 func (me *PsqlDB) FindAccessLogsByPubkey(pubkey string, fromDate *time.Time) ([]*db.AccessLog, error) {
13 	var logs []*db.AccessLog
14-	err := me.Db.Select(&logs, `SELECT * FROM access_logs WHERE pubkey=$1 AND created_at >= $2 ORDER BY created_at DESC`, pubkey, fromDate)
15+	err := me.Db.Select(&logs, `SELECT * FROM access_logs WHERE pubkey=$1 AND created_at >= $2 ORDER BY created_at ASC`, pubkey, fromDate)
16 	if err != nil {
17 		return nil, err
18 	}
A pkg/tui/access_logs.go
+247, -0
  1@@ -0,0 +1,247 @@
  2+package tui
  3+
  4+import (
  5+	"fmt"
  6+	"time"
  7+
  8+	"git.sr.ht/~rockorager/vaxis"
  9+	"git.sr.ht/~rockorager/vaxis/vxfw"
 10+	"git.sr.ht/~rockorager/vaxis/vxfw/list"
 11+	"git.sr.ht/~rockorager/vaxis/vxfw/richtext"
 12+	"git.sr.ht/~rockorager/vaxis/vxfw/text"
 13+	"github.com/picosh/pico/pkg/db"
 14+)
 15+
 16+type AccessLogsLoaded struct{}
 17+
 18+type AccessLogsPage struct {
 19+	shared *SharedModel
 20+
 21+	input    *TextInput
 22+	list     *list.Dynamic
 23+	filtered []int
 24+	logs     []*db.AccessLog
 25+	loading  bool
 26+	err      error
 27+	focus    string
 28+}
 29+
 30+func NewAccessLogsPage(shrd *SharedModel) *AccessLogsPage {
 31+	page := &AccessLogsPage{
 32+		shared: shrd,
 33+		input:  NewTextInput("filter logs"),
 34+		focus:  "input",
 35+	}
 36+	page.list = &list.Dynamic{Builder: page.getWidget, DrawCursor: true}
 37+	return page
 38+}
 39+
 40+func (m *AccessLogsPage) Footer() []Shortcut {
 41+	return []Shortcut{
 42+		{Shortcut: "tab", Text: "focus"},
 43+		{Shortcut: "^r", Text: "refresh"},
 44+	}
 45+}
 46+
 47+func (m *AccessLogsPage) filterLogLine(match string, ll *db.AccessLog) bool {
 48+	if match == "" {
 49+		return true
 50+	}
 51+
 52+	serviceMatch := matched(ll.Service, match)
 53+	identityMatch := matched(ll.Identity, match)
 54+	pubkeyMatch := matched(ll.Pubkey, match)
 55+
 56+	return serviceMatch || identityMatch || pubkeyMatch
 57+}
 58+
 59+func (m *AccessLogsPage) filterLogs() {
 60+	if m.loading || len(m.logs) == 0 {
 61+		m.filtered = []int{}
 62+		return
 63+	}
 64+
 65+	match := m.input.GetValue()
 66+	filtered := []int{}
 67+	for idx, ll := range m.logs {
 68+		if m.filterLogLine(match, ll) {
 69+			filtered = append(filtered, idx)
 70+		}
 71+	}
 72+
 73+	m.filtered = filtered
 74+
 75+	if len(filtered) > 0 {
 76+		m.list.SetCursor(uint(len(filtered) - 1))
 77+	}
 78+}
 79+
 80+func (m *AccessLogsPage) CaptureEvent(ev vaxis.Event) (vxfw.Command, error) {
 81+	switch msg := ev.(type) {
 82+	case vaxis.Key:
 83+		if msg.Matches(vaxis.KeyTab) {
 84+			return nil, nil
 85+		}
 86+		if m.focus == "input" {
 87+			m.filterLogs()
 88+			return vxfw.RedrawCmd{}, nil
 89+		}
 90+	}
 91+	return nil, nil
 92+}
 93+
 94+func (m *AccessLogsPage) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
 95+	switch msg := ev.(type) {
 96+	case PageIn:
 97+		m.loading = true
 98+		go m.fetchLogs()
 99+		m.focus = "input"
100+		return m.input.FocusIn()
101+	case AccessLogsLoaded:
102+		return vxfw.RedrawCmd{}, nil
103+	case vaxis.Key:
104+		if msg.Matches(vaxis.KeyTab) {
105+			if m.focus == "input" {
106+				m.focus = "access logs"
107+				cmd, _ := m.input.FocusOut()
108+				return vxfw.BatchCmd([]vxfw.Command{
109+					vxfw.FocusWidgetCmd(m.list),
110+					cmd,
111+				}), nil
112+			}
113+			m.focus = "input"
114+			cmd, _ := m.input.FocusIn()
115+			return vxfw.BatchCmd([]vxfw.Command{
116+				cmd,
117+				vxfw.RedrawCmd{},
118+			}), nil
119+		}
120+		if msg.Matches('r', vaxis.ModCtrl) {
121+			m.loading = true
122+			go m.fetchLogs()
123+			return vxfw.RedrawCmd{}, nil
124+		}
125+	}
126+	return nil, nil
127+}
128+
129+func (m *AccessLogsPage) fetchLogs() {
130+	if m.shared.User == nil {
131+		m.err = fmt.Errorf("no user found")
132+		m.loading = false
133+		m.shared.App.PostEvent(AccessLogsLoaded{})
134+		return
135+	}
136+
137+	fromDate := time.Now().AddDate(0, 0, -30)
138+	logs, err := m.shared.Dbpool.FindAccessLogs(m.shared.User.ID, &fromDate)
139+
140+	m.loading = false
141+	if err != nil {
142+		m.err = err
143+		m.logs = []*db.AccessLog{}
144+	} else {
145+		m.err = nil
146+		m.logs = logs
147+	}
148+
149+	m.filterLogs()
150+	m.shared.App.PostEvent(AccessLogsLoaded{})
151+}
152+
153+func (m *AccessLogsPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
154+	w := ctx.Max.Width
155+	h := ctx.Max.Height
156+	root := vxfw.NewSurface(w, h, m)
157+	ah := 0
158+
159+	logsLen := len(m.logs)
160+	filteredLen := len(m.filtered)
161+	err := m.err
162+
163+	info := text.New("Access logs in the last 30 days.  Access logs show SSH connections to pico services.")
164+	brd := NewBorder(info)
165+	brd.Label = "desc"
166+	brdSurf, _ := brd.Draw(ctx)
167+	root.AddChild(0, ah, brdSurf)
168+	ah += int(brdSurf.Size.Height)
169+
170+	if err != nil {
171+		txt := text.New(fmt.Sprintf("Error: %s", err.Error()))
172+		txt.Style = vaxis.Style{Foreground: red}
173+		txtSurf, _ := txt.Draw(ctx)
174+		root.AddChild(0, ah, txtSurf)
175+		ah += int(txtSurf.Size.Height)
176+	} else if logsLen == 0 {
177+		txt := text.New("No access logs found.")
178+		txtSurf, _ := txt.Draw(ctx)
179+		root.AddChild(0, ah, txtSurf)
180+		ah += int(txtSurf.Size.Height)
181+	} else if filteredLen == 0 {
182+		txt := text.New("No logs match filter.")
183+		txtSurf, _ := txt.Draw(ctx)
184+		root.AddChild(0, ah, txtSurf)
185+		ah += int(txtSurf.Size.Height)
186+	} else {
187+		listPane := NewBorder(m.list)
188+		listPane.Label = "access logs"
189+		m.focusBorder(listPane)
190+		listSurf, _ := listPane.Draw(createDrawCtx(ctx, ctx.Max.Height-uint16(ah)-3))
191+		root.AddChild(0, ah, listSurf)
192+		ah += int(listSurf.Size.Height)
193+	}
194+
195+	inp, _ := m.input.Draw(createDrawCtx(ctx, 4))
196+	root.AddChild(0, ah, inp)
197+
198+	return root, nil
199+}
200+
201+func (m *AccessLogsPage) focusBorder(brd *Border) {
202+	focus := m.focus
203+	if focus == brd.Label {
204+		brd.Style = vaxis.Style{Foreground: oj}
205+	} else {
206+		brd.Style = vaxis.Style{Foreground: purp}
207+	}
208+}
209+
210+func (m *AccessLogsPage) getWidget(i uint, cursor uint) vxfw.Widget {
211+	if len(m.filtered) == 0 {
212+		return nil
213+	}
214+
215+	if int(i) >= len(m.filtered) {
216+		return nil
217+	}
218+
219+	idx := m.filtered[i]
220+	return accessLogToWidget(m.logs[idx])
221+}
222+
223+func accessLogToWidget(log *db.AccessLog) vxfw.Widget {
224+	dateStr := ""
225+	if log.CreatedAt != nil {
226+		dateStr = log.CreatedAt.Format(time.RFC3339)
227+	}
228+
229+	segs := []vaxis.Segment{
230+		{Text: dateStr + " "},
231+		{Text: log.Service + " ", Style: vaxis.Style{Foreground: purp}},
232+		{Text: log.Identity + " ", Style: vaxis.Style{Foreground: green}},
233+	}
234+
235+	if log.Pubkey != "" {
236+		fingerprint := log.Pubkey
237+		if len(fingerprint) > 16 {
238+			fingerprint = fingerprint[0:25] + "..." + fingerprint[len(fingerprint)-15:]
239+		}
240+		segs = append(segs, vaxis.Segment{
241+			Text:  fingerprint,
242+			Style: vaxis.Style{Foreground: grey},
243+		})
244+	}
245+
246+	txt := richtext.New(segs)
247+	return txt
248+}
M pkg/tui/input.go
+0, -7
 1@@ -40,13 +40,6 @@ func (m *TextInput) FocusOut() (vxfw.Command, error) {
 2 }
 3 
 4 func (m *TextInput) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
 5-	switch msg := ev.(type) {
 6-	case vaxis.Key:
 7-		if msg.Matches(vaxis.KeyTab) {
 8-			m.focus = !m.focus
 9-			return vxfw.RedrawCmd{}, nil
10-		}
11-	}
12 	return nil, nil
13 }
14 
M pkg/tui/menu.go
+1, -0
1@@ -12,6 +12,7 @@ var menuChoices = []string{
2 	"pubkeys",
3 	"tokens",
4 	"logs",
5+	"access_logs",
6 	"analytics",
7 	"tuns",
8 	"pico+",
M pkg/tui/ui.go
+12, -11
 1@@ -343,17 +343,18 @@ func NewTui(opts vaxis.Options, shrd *SharedModel) error {
 2 
 3 	shrd.App = app
 4 	pages := map[string]vxfw.Widget{
 5-		HOME:         NewMenuPage(shrd),
 6-		"pubkeys":    NewPubkeysPage(shrd),
 7-		"add-pubkey": NewAddPubkeyPage(shrd),
 8-		"tokens":     NewTokensPage(shrd),
 9-		"add-token":  NewAddTokenPage(shrd),
10-		"signup":     NewSignupPage(shrd),
11-		"pico+":      NewPlusPage(shrd),
12-		"logs":       NewLogsPage(shrd),
13-		"analytics":  NewAnalyticsPage(shrd),
14-		"chat":       NewChatPage(shrd),
15-		"tuns":       NewTunsPage(shrd),
16+		HOME:          NewMenuPage(shrd),
17+		"pubkeys":     NewPubkeysPage(shrd),
18+		"add-pubkey":  NewAddPubkeyPage(shrd),
19+		"tokens":      NewTokensPage(shrd),
20+		"add-token":   NewAddTokenPage(shrd),
21+		"signup":      NewSignupPage(shrd),
22+		"pico+":       NewPlusPage(shrd),
23+		"logs":        NewLogsPage(shrd),
24+		"analytics":   NewAnalyticsPage(shrd),
25+		"chat":        NewChatPage(shrd),
26+		"tuns":        NewTunsPage(shrd),
27+		"access_logs": NewAccessLogsPage(shrd),
28 	}
29 	root := &App{
30 		shared: shrd,