- commit
- 77553f3
- parent
- 828bab7
- author
- Eric Bower
- date
- 2024-06-16 16:22:54 +0000 UTC
feat(prose): cli cmd `stats {post}` for analytics
4 files changed,
+255,
-41
+3,
-38
1@@ -6,7 +6,6 @@ import (
2 "log/slog"
3 "path/filepath"
4 "strings"
5- "time"
6
7 "github.com/charmbracelet/lipgloss"
8 "github.com/charmbracelet/lipgloss/table"
9@@ -196,40 +195,6 @@ func (c *Cmd) help() {
10 c.output(getHelpText(c.Styles, c.User.Name, c.Width))
11 }
12
13-func uniqueVisitorsTbl(intervals []*db.VisitInterval) *table.Table {
14- headers := []string{"Date", "Unique Visitors"}
15- data := [][]string{}
16- for _, d := range intervals {
17- data = append(data, []string{
18- d.Interval.Format(time.RFC3339Nano),
19- fmt.Sprintf("%d", d.Visitors),
20- })
21- }
22-
23- t := table.New().
24- Border(lipgloss.RoundedBorder()).
25- Headers(headers...).
26- Rows(data...)
27- return t
28-}
29-
30-func visitUrlsTbl(urls []*db.VisitUrl) *table.Table {
31- headers := []string{"Site", "Count"}
32- data := [][]string{}
33- for _, d := range urls {
34- data = append(data, []string{
35- d.Url,
36- fmt.Sprintf("%d", d.Count),
37- })
38- }
39-
40- t := table.New().
41- Border(lipgloss.RoundedBorder()).
42- Headers(headers...).
43- Rows(data...)
44- return t
45-}
46-
47 func (c *Cmd) statsByProject(projectName string) error {
48 project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
49 if err != nil {
50@@ -249,14 +214,14 @@ func (c *Cmd) statsByProject(projectName string) error {
51 }
52
53 c.output("Top URLs")
54- topUrlsTbl := visitUrlsTbl(summary.TopUrls)
55+ topUrlsTbl := common.VisitUrlsTbl(summary.TopUrls)
56 c.output(topUrlsTbl.Width(c.Width).String())
57
58 c.output("Top Referers")
59- topRefsTbl := visitUrlsTbl(summary.TopReferers)
60+ topRefsTbl := common.VisitUrlsTbl(summary.TopReferers)
61 c.output(topRefsTbl.Width(c.Width).String())
62
63- uniqueTbl := uniqueVisitorsTbl(summary.Intervals)
64+ uniqueTbl := common.UniqueVisitorsTbl(summary.Intervals)
65 c.output("Unique Visitors this Month")
66 c.output(uniqueTbl.Width(c.Width).String())
67
+198,
-0
1@@ -0,0 +1,198 @@
2+package prose
3+
4+import (
5+ "errors"
6+ "fmt"
7+ "log/slog"
8+ "strings"
9+
10+ "github.com/charmbracelet/ssh"
11+ "github.com/charmbracelet/wish"
12+ bm "github.com/charmbracelet/wish/bubbletea"
13+ "github.com/picosh/pico/db"
14+ "github.com/picosh/pico/shared"
15+ "github.com/picosh/pico/tui/common"
16+)
17+
18+func getUser(s ssh.Session, dbpool db.DB) (*db.User, error) {
19+ var err error
20+ key, err := shared.KeyText(s)
21+ if err != nil {
22+ return nil, fmt.Errorf("key not found")
23+ }
24+
25+ user, err := dbpool.FindUserForKey(s.User(), key)
26+ if err != nil {
27+ return nil, err
28+ }
29+
30+ if user.Name == "" {
31+ return nil, fmt.Errorf("must have username set")
32+ }
33+
34+ return user, nil
35+}
36+
37+type Cmd struct {
38+ User *db.User
39+ Session shared.CmdSession
40+ Log *slog.Logger
41+ Dbpool db.DB
42+ Styles common.Styles
43+ Width int
44+ Height int
45+}
46+
47+func (c *Cmd) output(out string) {
48+ _, _ = c.Session.Write([]byte(out + "\r\n"))
49+}
50+
51+func (c *Cmd) help() {
52+ helpStr := "Commands: [help, stats]\n"
53+ c.output(helpStr)
54+}
55+
56+func (c *Cmd) statsByPost(postSlug string) error {
57+ post, err := c.Dbpool.FindPostWithSlug(postSlug, c.User.ID, "prose")
58+ if err != nil {
59+ return errors.Join(err, fmt.Errorf("post (%s) does not exit", postSlug))
60+ }
61+
62+ opts := &db.SummaryOpts{
63+ FkID: post.ID,
64+ By: "post_id",
65+ Interval: "day",
66+ Origin: shared.StartOfMonth(),
67+ }
68+
69+ summary, err := c.Dbpool.VisitSummary(opts)
70+ if err != nil {
71+ return err
72+ }
73+
74+ c.output("Top URLs")
75+ topUrlsTbl := common.VisitUrlsTbl(summary.TopUrls)
76+ c.output(topUrlsTbl.Width(c.Width).String())
77+
78+ c.output("Top Referers")
79+ topRefsTbl := common.VisitUrlsTbl(summary.TopReferers)
80+ c.output(topRefsTbl.Width(c.Width).String())
81+
82+ uniqueTbl := common.UniqueVisitorsTbl(summary.Intervals)
83+ c.output("Unique Visitors this Month")
84+ c.output(uniqueTbl.Width(c.Width).String())
85+
86+ return nil
87+}
88+
89+func (c *Cmd) stats() error {
90+ opts := &db.SummaryOpts{
91+ FkID: c.User.ID,
92+ By: "user_id",
93+ Interval: "day",
94+ Origin: shared.StartOfMonth(),
95+ Where: "AND (post_id IS NOT NULL OR (post_id IS NULL AND project_id IS NULL))",
96+ }
97+
98+ summary, err := c.Dbpool.VisitSummary(opts)
99+ if err != nil {
100+ return err
101+ }
102+
103+ c.output("Top URLs")
104+ topUrlsTbl := common.VisitUrlsTbl(summary.TopUrls)
105+ c.output(topUrlsTbl.Width(c.Width).String())
106+
107+ c.output("Top Referers")
108+ topRefsTbl := common.VisitUrlsTbl(summary.TopReferers)
109+ c.output(topRefsTbl.Width(c.Width).String())
110+
111+ uniqueTbl := common.UniqueVisitorsTbl(summary.Intervals)
112+ c.output("Unique Visitors this Month")
113+ c.output(uniqueTbl.Width(c.Width).String())
114+
115+ return nil
116+}
117+
118+type CliHandler struct {
119+ DBPool db.DB
120+ Logger *slog.Logger
121+}
122+
123+func WishMiddleware(handler *CliHandler) wish.Middleware {
124+ dbpool := handler.DBPool
125+ log := handler.Logger
126+
127+ return func(next ssh.Handler) ssh.Handler {
128+ return func(sesh ssh.Session) {
129+ args := sesh.Command()
130+ if len(args) == 0 {
131+ next(sesh)
132+ return
133+ }
134+
135+ // default width and height when no pty
136+ width := 80
137+ height := 24
138+ pty, _, ok := sesh.Pty()
139+ if ok {
140+ width = pty.Window.Width
141+ height = pty.Window.Height
142+ }
143+
144+ user, err := getUser(sesh, dbpool)
145+ if err != nil {
146+ wish.Errorln(sesh, err)
147+ return
148+ }
149+
150+ renderer := bm.MakeRenderer(sesh)
151+ styles := common.DefaultStyles(renderer)
152+
153+ opts := Cmd{
154+ Session: sesh,
155+ User: user,
156+ Log: log,
157+ Dbpool: dbpool,
158+ Styles: styles,
159+ Width: width,
160+ Height: height,
161+ }
162+
163+ cmd := strings.TrimSpace(args[0])
164+ if len(args) == 1 {
165+ if cmd == "help" {
166+ opts.help()
167+ return
168+ } else if cmd == "stats" {
169+ opts.stats()
170+ return
171+ } else {
172+ next(sesh)
173+ return
174+ }
175+ }
176+
177+ postSlug := strings.TrimSpace(args[1])
178+ cmdArgs := args[2:]
179+ log.Info(
180+ "pgs middleware detected command",
181+ "args", args,
182+ "cmd", cmd,
183+ "post", postSlug,
184+ "cmdArgs", cmdArgs,
185+ )
186+
187+ if cmd == "stats" {
188+ err := opts.statsByPost(postSlug)
189+ if err != nil {
190+ wish.Fatalln(sesh, err)
191+ }
192+ return
193+ } else {
194+ next(sesh)
195+ return
196+ }
197+ }
198+ }
199+}
+10,
-3
1@@ -32,7 +32,7 @@ func (me *SSHServer) authHandler(ctx ssh.Context, key ssh.PublicKey) bool {
2 return true
3 }
4
5-func createRouter(handler *filehandlers.FileHandlerRouter) proxy.Router {
6+func createRouter(handler *filehandlers.FileHandlerRouter, cliHandler *CliHandler) proxy.Router {
7 return func(sh ssh.Handler, s ssh.Session) []wish.Middleware {
8 return []wish.Middleware{
9 pipe.Middleware(handler, ".md"),
10@@ -41,19 +41,20 @@ func createRouter(handler *filehandlers.FileHandlerRouter) proxy.Router {
11 wishrsync.Middleware(handler),
12 auth.Middleware(handler),
13 wsh.PtyMdw(wsh.DeprecatedNotice()),
14+ WishMiddleware(cliHandler),
15 wsh.LogMiddleware(handler.GetLogger()),
16 }
17 }
18 }
19
20-func withProxy(handler *filehandlers.FileHandlerRouter, otherMiddleware ...wish.Middleware) ssh.Option {
21+func withProxy(handler *filehandlers.FileHandlerRouter, cliHandler *CliHandler, otherMiddleware ...wish.Middleware) ssh.Option {
22 return func(server *ssh.Server) error {
23 err := sftp.SSHOption(handler)(server)
24 if err != nil {
25 return err
26 }
27
28- return proxy.WithProxy(createRouter(handler), otherMiddleware...)(server)
29+ return proxy.WithProxy(createRouter(handler, cliHandler), otherMiddleware...)(server)
30 }
31 }
32
33@@ -91,6 +92,11 @@ func StartSshServer() {
34 handler := filehandlers.NewFileHandlerRouter(cfg, dbh, fileMap)
35 handler.Spaces = []string{cfg.Space, "imgs"}
36
37+ cliHandler := &CliHandler{
38+ Logger: logger,
39+ DBPool: dbh,
40+ }
41+
42 sshServer := &SSHServer{}
43 s, err := wish.NewServer(
44 wish.WithAddress(fmt.Sprintf("%s:%s", host, port)),
45@@ -98,6 +104,7 @@ func StartSshServer() {
46 wish.WithPublicKeyAuth(sshServer.authHandler),
47 withProxy(
48 handler,
49+ cliHandler,
50 promwish.Middleware(fmt.Sprintf("%s:%s", host, promPort), "prose-ssh"),
51 ),
52 )
+44,
-0
1@@ -0,0 +1,44 @@
2+package common
3+
4+import (
5+ "fmt"
6+ "time"
7+
8+ "github.com/charmbracelet/lipgloss"
9+ "github.com/charmbracelet/lipgloss/table"
10+ "github.com/picosh/pico/db"
11+)
12+
13+func UniqueVisitorsTbl(intervals []*db.VisitInterval) *table.Table {
14+ headers := []string{"Date", "Unique Visitors"}
15+ data := [][]string{}
16+ for _, d := range intervals {
17+ data = append(data, []string{
18+ d.Interval.Format(time.RFC3339Nano),
19+ fmt.Sprintf("%d", d.Visitors),
20+ })
21+ }
22+
23+ t := table.New().
24+ Border(lipgloss.RoundedBorder()).
25+ Headers(headers...).
26+ Rows(data...)
27+ return t
28+}
29+
30+func VisitUrlsTbl(urls []*db.VisitUrl) *table.Table {
31+ headers := []string{"Site", "Count"}
32+ data := [][]string{}
33+ for _, d := range urls {
34+ data = append(data, []string{
35+ d.Url,
36+ fmt.Sprintf("%d", d.Count),
37+ })
38+ }
39+
40+ t := table.New().
41+ Border(lipgloss.RoundedBorder()).
42+ Headers(headers...).
43+ Rows(data...)
44+ return t
45+}