Commit 43cf6b2

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