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) { | |
| 1479 | 1479 | ||
| 1480 | 1480 | func (me *PsqlDB) FindAccessLogs(userID string, fromDate *time.Time) ([]*db.AccessLog, error) { | |
| 1481 | 1481 | 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) | |
| 1483 | 1483 | if err != nil { | |
| 1484 | 1484 | return nil, err | |
| 1485 | 1485 | } |
| ... | ... | @@ -1488,7 +1488,7 @@ func (me *PsqlDB) FindAccessLogs(userID string, fromDate *time.Time) ([]*db.Acce | |
| 1488 | 1488 | ||
| 1489 | 1489 | func (me *PsqlDB) FindAccessLogsByPubkey(pubkey string, fromDate *time.Time) ([]*db.AccessLog, error) { | |
| 1490 | 1490 | 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) | |
| 1492 | 1492 | if err != nil { | |
| 1493 | 1493 | return nil, err | |
| 1494 | 1494 | } |
+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) { | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | 42 | 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 | - | } | |
| 50 | 43 | return nil, nil | |
| 51 | 44 | } | |
| 52 | 45 |
| ... | ... | @@ -12,6 +12,7 @@ var menuChoices = []string{ | |
| 12 | 12 | "pubkeys", | |
| 13 | 13 | "tokens", | |
| 14 | 14 | "logs", | |
| 15 | + | "access_logs", | |
| 15 | 16 | "analytics", | |
| 16 | 17 | "tuns", | |
| 17 | 18 | "pico+", |
+12,
-11
| ... | ... | @@ -343,17 +343,18 @@ func NewTui(opts vaxis.Options, shrd *SharedModel) error { | |
| 343 | 343 | ||
| 344 | 344 | shrd.App = app | |
| 345 | 345 | 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), | |
| 357 | 358 | } | |
| 358 | 359 | root := &App{ | |
| 359 | 360 | shared: shrd, |