- 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
+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 }
+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+}
+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
1@@ -12,6 +12,7 @@ var menuChoices = []string{
2 "pubkeys",
3 "tokens",
4 "logs",
5+ "access_logs",
6 "analytics",
7 "tuns",
8 "pico+",
+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,