- commit
- 1133a93
- parent
- 967f81f
- author
- Eric Bower
- date
- 2024-10-05 16:26:14 +0000 UTC
feat(tui): logs
6 files changed,
+278,
-39
+4,
-38
1@@ -67,42 +67,11 @@ func (c *Cmd) notifications() error {
2 }
3
4 func (c *Cmd) logs(ctx context.Context) error {
5- sshClient, err := shared.CreateSSHClient(
6- shared.GetEnv("PICO_SENDLOG_ENDPOINT", "send.pico.sh:22"),
7- shared.GetEnv("PICO_SENDLOG_KEY", "ssh_data/term_info_ed25519"),
8- shared.GetEnv("PICO_SENDLOG_PASSPHRASE", ""),
9- shared.GetEnv("PICO_SENDLOG_REMOTE_HOST", "send.pico.sh"),
10- shared.GetEnv("PICO_SENDLOG_USER", "pico"),
11- )
12+ stdoutPipe, err := shared.ConnectToLogs(ctx)
13 if err != nil {
14 return err
15 }
16
17- defer sshClient.Close()
18-
19- session, err := sshClient.NewSession()
20- if err != nil {
21- return err
22- }
23-
24- defer session.Close()
25-
26- stdoutPipe, err := session.StdoutPipe()
27- if err != nil {
28- return err
29- }
30-
31- err = session.Start("sub log-drain -k")
32- if err != nil {
33- return err
34- }
35-
36- go func() {
37- <-ctx.Done()
38- session.Close()
39- sshClient.Close()
40- }()
41-
42 scanner := bufio.NewScanner(stdoutPipe)
43 for scanner.Scan() {
44 line := scanner.Text()
45@@ -114,12 +83,9 @@ func (c *Cmd) logs(ctx context.Context) error {
46 continue
47 }
48
49- if userName, ok := parsedData["user"]; ok {
50- if userName, ok := userName.(string); ok {
51- if userName == c.User.Name {
52- wish.Println(c.SshSession, line)
53- }
54- }
55+ user := shared.AnyToStr(parsedData, "user")
56+ if user == c.User.Name {
57+ wish.Println(c.SshSession, line)
58 }
59 }
60 return scanner.Err()
1@@ -330,3 +330,57 @@ func SendLogRegister(logger *slog.Logger, buffer int) (*slog.Logger, error) {
2
3 var _ io.Writer = (*SendLogWriter)(nil)
4 var _ slog.Handler = (*MultiHandler)(nil)
5+
6+func AnyToStr(mp map[string]any, key string) string {
7+ if value, ok := mp[key]; ok {
8+ if value, ok := value.(string); ok {
9+ return value
10+ }
11+ }
12+ return ""
13+}
14+
15+func AnyToInt(mp map[string]any, key string) int64 {
16+ if value, ok := mp[key]; ok {
17+ if value, ok := value.(int64); ok {
18+ return value
19+ }
20+ }
21+ return 0
22+}
23+
24+func ConnectToLogs(ctx context.Context) (io.Reader, error) {
25+ sshClient, err := CreateSSHClient(
26+ GetEnv("PICO_SENDLOG_ENDPOINT", "send.pico.sh:22"),
27+ GetEnv("PICO_SENDLOG_KEY", "ssh_data/term_info_ed25519"),
28+ GetEnv("PICO_SENDLOG_PASSPHRASE", ""),
29+ GetEnv("PICO_SENDLOG_REMOTE_HOST", "send.pico.sh"),
30+ GetEnv("PICO_SENDLOG_USER", "pico"),
31+ )
32+ if err != nil {
33+ return nil, err
34+ }
35+
36+ session, err := sshClient.NewSession()
37+ if err != nil {
38+ return nil, err
39+ }
40+
41+ stdoutPipe, err := session.StdoutPipe()
42+ if err != nil {
43+ return nil, err
44+ }
45+
46+ err = session.Start("sub log-drain -k")
47+ if err != nil {
48+ return nil, err
49+ }
50+
51+ go func() {
52+ <-ctx.Done()
53+ session.Close()
54+ sshClient.Close()
55+ }()
56+
57+ return stdoutPipe, nil
58+}
+210,
-0
1@@ -0,0 +1,210 @@
2+package logs
3+
4+import (
5+ "bufio"
6+ "encoding/json"
7+ "fmt"
8+ "strings"
9+
10+ input "github.com/charmbracelet/bubbles/textinput"
11+ "github.com/charmbracelet/bubbles/viewport"
12+ tea "github.com/charmbracelet/bubbletea"
13+ "github.com/charmbracelet/lipgloss"
14+ "github.com/picosh/pico/shared"
15+ "github.com/picosh/pico/tui/common"
16+ "github.com/picosh/pico/tui/pages"
17+)
18+
19+type state int
20+
21+const (
22+ stateLoading state = iota
23+ stateReady
24+)
25+
26+type logLineLoadedMsg map[string]any
27+
28+type Model struct {
29+ shared common.SharedModel
30+ state state
31+ logData []map[string]any
32+ viewport viewport.Model
33+ input input.Model
34+ sub chan map[string]any
35+}
36+
37+func headerHeight(shrd common.SharedModel) int {
38+ return shrd.HeaderHeight
39+}
40+
41+func headerWidth(w int) int {
42+ return w - 2
43+}
44+
45+func NewModel(shrd common.SharedModel) Model {
46+ im := input.New()
47+ im.Cursor.Style = shrd.Styles.Cursor
48+ im.Placeholder = "filter logs"
49+ im.PlaceholderStyle = shrd.Styles.InputPlaceholder
50+ im.Prompt = shrd.Styles.FocusedPrompt.String()
51+ im.CharLimit = 50
52+ im.Focus()
53+
54+ hh := headerHeight(shrd)
55+ ww := headerWidth(shrd.Width)
56+ inputHeight := lipgloss.Height(im.View())
57+ viewport := viewport.New(ww, shrd.Height-hh-inputHeight)
58+ viewport.YPosition = hh
59+
60+ return Model{
61+ shared: shrd,
62+ state: stateReady,
63+ viewport: viewport,
64+ logData: []map[string]any{},
65+ input: im,
66+ sub: make(chan map[string]any),
67+ }
68+}
69+
70+func (m Model) Init() tea.Cmd {
71+ return tea.Batch(
72+ m.connectLogs(m.sub),
73+ waitForActivity(m.sub),
74+ )
75+}
76+
77+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
78+ var cmds []tea.Cmd
79+ var cmd tea.Cmd
80+ switch msg := msg.(type) {
81+ case tea.WindowSizeMsg:
82+ m.viewport.Width = headerWidth(msg.Width)
83+ inputHeight := lipgloss.Height(m.input.View())
84+ hh := headerHeight(m.shared)
85+ m.viewport.Height = msg.Height - hh - inputHeight
86+ case logLineLoadedMsg:
87+ m.logData = append(m.logData, msg)
88+ wasAt := false
89+ if m.viewport.AtBottom() {
90+ wasAt = true
91+ }
92+ m.viewport.SetContent(logsToStr(m.logData, m.input.Value()))
93+ if wasAt {
94+ m.viewport.GotoBottom()
95+ }
96+ cmds = append(cmds, waitForActivity(m.sub))
97+ case tea.KeyMsg:
98+ switch msg.String() {
99+ case "q", "esc":
100+ return m, pages.Navigate(pages.MenuPage)
101+ case "tab":
102+ if m.input.Focused() {
103+ m.input.Blur()
104+ } else {
105+ cmds = append(cmds, m.input.Focus())
106+ }
107+ default:
108+ m.viewport.SetContent(logsToStr(m.logData, m.input.Value()))
109+ m.viewport.GotoBottom()
110+ }
111+ }
112+ m.input, cmd = m.input.Update(msg)
113+ cmds = append(cmds, cmd)
114+ m.viewport, cmd = m.viewport.Update(msg)
115+ cmds = append(cmds, cmd)
116+ return m, tea.Batch(cmds...)
117+}
118+
119+func (m Model) View() string {
120+ if m.state == stateLoading {
121+ return "Loading ..."
122+ }
123+ return m.viewport.View() + "\n" + m.input.View()
124+}
125+
126+func waitForActivity(sub chan map[string]any) tea.Cmd {
127+ return func() tea.Msg {
128+ result := <-sub
129+ return logLineLoadedMsg(result)
130+ }
131+}
132+
133+func (m Model) connectLogs(sub chan map[string]any) tea.Cmd {
134+ return func() tea.Msg {
135+ stdoutPipe, err := shared.ConnectToLogs(m.shared.Session.Context())
136+ if err != nil {
137+ fmt.Println(err)
138+ return nil
139+ }
140+
141+ scanner := bufio.NewScanner(stdoutPipe)
142+ for scanner.Scan() {
143+ line := scanner.Text()
144+ parsedData := map[string]any{}
145+
146+ err := json.Unmarshal([]byte(line), &parsedData)
147+ if err != nil {
148+ m.shared.Logger.Error("json unmarshal", "err", err)
149+ continue
150+ }
151+
152+ user := shared.AnyToStr(parsedData, "user")
153+ if user == m.shared.User.Name {
154+ sub <- parsedData
155+ }
156+ }
157+
158+ return nil
159+ }
160+}
161+
162+func matched(str, match string) bool {
163+ prim := strings.ToLower(str)
164+ mtch := strings.ToLower(match)
165+ return strings.Contains(prim, mtch)
166+}
167+
168+func logToStr(data map[string]any, match string) string {
169+ time := shared.AnyToStr(data, "time")
170+ service := shared.AnyToStr(data, "service")
171+ level := shared.AnyToStr(data, "level")
172+ msg := shared.AnyToStr(data, "msg")
173+ errMsg := shared.AnyToStr(data, "err")
174+ status := shared.AnyToInt(data, "status")
175+ url := shared.AnyToStr(data, "url")
176+
177+ if match != "" {
178+ lvlMatch := matched(level, match)
179+ msgMatch := matched(msg, match)
180+ serviceMatch := matched(service, match)
181+ errMatch := matched(errMsg, match)
182+ urlMatch := matched(url, match)
183+ if !lvlMatch && !msgMatch && !serviceMatch && !errMatch && !urlMatch {
184+ return ""
185+ }
186+ }
187+
188+ acc := fmt.Sprintf(
189+ "%s\t%s\t%s\t%s\t%s\t%d\t%s",
190+ time,
191+ service,
192+ level,
193+ msg,
194+ errMsg,
195+ status,
196+ url,
197+ )
198+ acc += "\n"
199+ return acc
200+}
201+
202+func logsToStr(data []map[string]any, filter string) string {
203+ acc := ""
204+ for _, d := range data {
205+ res := logToStr(d, filter)
206+ if res != "" {
207+ acc += res
208+ }
209+ }
210+ return acc
211+}
1@@ -19,6 +19,7 @@ const (
2 NotificationsChoice
3 PlusChoice
4 SettingsChoice
5+ LogsChoice
6 ChatChoice
7 ExitChoice
8 UnsetChoice // set when no choice has been made
9@@ -31,6 +32,7 @@ var menuChoices = map[menuChoice]string{
10 NotificationsChoice: "Notifications",
11 PlusChoice: "Pico+",
12 SettingsChoice: "Settings",
13+ LogsChoice: "Logs",
14 ChatChoice: "Chat",
15 ExitChoice: "Exit",
16 }
+3,
-0
1@@ -14,6 +14,7 @@ const (
2 NotificationsPage
3 PlusPage
4 SettingsPage
5+ LogsPage
6 )
7
8 type NavigateMsg struct{ Page }
9@@ -44,6 +45,8 @@ func ToTitle(page Page) string {
10 return "pubkeys"
11 case SettingsPage:
12 return "settings"
13+ case LogsPage:
14+ return "logs"
15 }
16
17 return ""
+5,
-1
1@@ -12,6 +12,7 @@ import (
2 "github.com/picosh/pico/tui/createaccount"
3 "github.com/picosh/pico/tui/createkey"
4 "github.com/picosh/pico/tui/createtoken"
5+ "github.com/picosh/pico/tui/logs"
6 "github.com/picosh/pico/tui/menu"
7 "github.com/picosh/pico/tui/notifications"
8 "github.com/picosh/pico/tui/pages"
9@@ -41,7 +42,7 @@ func NewUI(shared common.SharedModel) *UI {
10 m := &UI{
11 shared: shared,
12 state: initState,
13- pages: make([]tea.Model, 9),
14+ pages: make([]tea.Model, 10),
15 }
16 return m
17 }
18@@ -76,6 +77,7 @@ func (m *UI) Init() tea.Cmd {
19 m.pages[pages.NotificationsPage] = notifications.NewModel(m.shared)
20 m.pages[pages.PlusPage] = plus.NewModel(m.shared)
21 m.pages[pages.SettingsPage] = settings.NewModel(m.shared)
22+ m.pages[pages.LogsPage] = logs.NewModel(m.shared)
23 if m.shared.User == nil {
24 m.activePage = pages.CreateAccountPage
25 } else {
26@@ -137,6 +139,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
27 m.activePage = pages.PlusPage
28 case menu.SettingsChoice:
29 m.activePage = pages.SettingsPage
30+ case menu.LogsChoice:
31+ m.activePage = pages.LogsPage
32 case menu.ChatChoice:
33 return m, LoadChat(m.shared)
34 case menu.ExitChoice: