repos / pico

pico services - prose.sh, pastes.sh, imgs.sh, feeds.sh, pgs.sh
git clone https://github.com/picosh/pico.git

commit
1133a93
parent
967f81f
author
Eric Bower
date
2024-10-05 16:26:14 +0000 UTC
feat(tui): logs
6 files changed,  +278, -39
M pico/cli.go
+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()
M shared/sendlog.go
+54, -0
 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+}
A tui/logs/logs.go
+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+}
M tui/menu/menu.go
+2, -0
 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 }
M tui/pages/pages.go
+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 ""
M tui/ui.go
+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: