repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

commit
13076a6
parent
0417ee1
author
Eric Bower
date
2025-03-19 18:54:07 -0400 EDT
feat(tui.tuns): conn events
9 files changed,  +282, -35
M Makefile
+2, -1
 1@@ -126,10 +126,11 @@ migrate:
 2 	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20241114_add_namespace_to_analytics.sql
 3 	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20241125_add_content_type_to_analytics.sql
 4 	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20241202_add_more_idx_analytics.sql
 5+	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20250319_add_tuns_event_logs_table.sql
 6 .PHONY: migrate
 7 
 8 latest:
 9-	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20241202_add_more_idx_analytics.sql
10+	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20250319_add_tuns_event_logs_table.sql
11 .PHONY: latest
12 
13 psql:
M pkg/apps/auth/__snapshots__/api_test.snap
+10, -10
 1@@ -112,12 +112,12 @@ successfully added pico+ user
 2     </author>
 3   </entry>
 4   <entry>
 5-    <title>pico+ 1-month expiration notice</title>
 6-    <updated>2021-07-16T14:30:45Z</updated>
 7-    <id>1626445845</id>
 8-    <content type="html">&#xA;&lt;html&gt;&#xA;&#x9;&lt;head&gt;&#xA;&#x9;&#x9;&lt;title&gt;pico+ 1-month expiration notification!&lt;/title&gt;&#xA;&#x9;&#x9;&lt;style&gt;&#xA;&#x9;&#x9;&#x9;code {&#xA;&#x9;&#x9;&#x9;&#x9;background-color: #ddd;&#xA;&#x9;&#x9;&#x9;&#x9;border-radius: 5px;&#xA;&#x9;&#x9;&#x9;&#x9;padding: 1px 3px;&#xA;&#x9;&#x9;&#x9;}&#xA;&#x9;&#x9;&lt;/style&gt;&#xA;&#x9;&lt;/head&gt;&#xA;&#x9;&lt;body&gt;&#xA;&#x9;&#x9;&lt;h1&gt;pico+ 1-month expiration notification!&lt;/h1&gt;&#xA;&lt;p&gt;&#xA;&#x9;Your &lt;code&gt;pico+&lt;/code&gt; membership will expire on &lt;strong&gt;2021-08-16&lt;/strong&gt;.&#xA;&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&#x9;If your pico+ membership expires then we will:&#xA;&#xA;&#x9;&lt;ul&gt;&#xA;&#x9;&#x9;&lt;li&gt;revoke access to &lt;a href=&#34;https&#34;&gt;https://tuns.sh&lt;/a&gt;&lt;/li&gt;&#xA;&#x9;&#x9;&lt;li&gt;reject new sites being created for &lt;a href=&#34;https://pgs.sh&#34;&gt;pgs.sh&lt;/a&gt;&lt;/li&gt;&#xA;&#x9;&#x9;&lt;li&gt;revoke access to our IRC bouncer&lt;/li&gt;&#xA;&#x9;&lt;/ul&gt;&#xA;&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&#x9;In order to continue using our premium services, you need to purchase another year:&#xA;&#x9;&lt;a href=&#34;https://auth.pico.sh/checkout/user-a&#34;&gt;purchase pico+&lt;/a&gt;&#xA;&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&#x9;If you have any questions, please do not hesitate to &lt;a href=&#34;https://pico.sh/contact&#34;&gt;contact us&lt;/a&gt;.&#xA;&lt;/p&gt;&#xA;&#x9;&lt;/body&gt;&#xA;&lt;/html&gt;&#xA;</content>
 9+    <title>pico+ 1-day expiration notice</title>
10+    <updated>2021-08-14T14:30:45Z</updated>
11+    <id>1628951445</id>
12+    <content type="html">&#xA;&lt;html&gt;&#xA;&#x9;&lt;head&gt;&#xA;&#x9;&#x9;&lt;title&gt;pico+ 1-day expiration notification!&lt;/title&gt;&#xA;&#x9;&#x9;&lt;style&gt;&#xA;&#x9;&#x9;&#x9;code {&#xA;&#x9;&#x9;&#x9;&#x9;background-color: #ddd;&#xA;&#x9;&#x9;&#x9;&#x9;border-radius: 5px;&#xA;&#x9;&#x9;&#x9;&#x9;padding: 1px 3px;&#xA;&#x9;&#x9;&#x9;}&#xA;&#x9;&#x9;&lt;/style&gt;&#xA;&#x9;&lt;/head&gt;&#xA;&#x9;&lt;body&gt;&#xA;&#x9;&#x9;&lt;h1&gt;pico+ 1-day expiration notification!&lt;/h1&gt;&#xA;&lt;p&gt;&#xA;&#x9;Your &lt;code&gt;pico+&lt;/code&gt; membership will expire on &lt;strong&gt;2021-08-16&lt;/strong&gt;.&#xA;&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&#x9;If your pico+ membership expires then we will:&#xA;&#xA;&#x9;&lt;ul&gt;&#xA;&#x9;&#x9;&lt;li&gt;revoke access to &lt;a href=&#34;https&#34;&gt;https://tuns.sh&lt;/a&gt;&lt;/li&gt;&#xA;&#x9;&#x9;&lt;li&gt;reject new sites being created for &lt;a href=&#34;https://pgs.sh&#34;&gt;pgs.sh&lt;/a&gt;&lt;/li&gt;&#xA;&#x9;&#x9;&lt;li&gt;revoke access to our IRC bouncer&lt;/li&gt;&#xA;&#x9;&lt;/ul&gt;&#xA;&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&#x9;In order to continue using our premium services, you need to purchase another year:&#xA;&#x9;&lt;a href=&#34;https://auth.pico.sh/checkout/user-a&#34;&gt;purchase pico+&lt;/a&gt;&#xA;&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&#x9;If you have any questions, please do not hesitate to &lt;a href=&#34;https://pico.sh/contact&#34;&gt;contact us&lt;/a&gt;.&#xA;&lt;/p&gt;&#xA;&#x9;&lt;/body&gt;&#xA;&lt;/html&gt;&#xA;</content>
13     <link href="https://pico.sh" rel="alternate"></link>
14-    <summary type="html">&#xA;&lt;html&gt;&#xA;&#x9;&lt;head&gt;&#xA;&#x9;&#x9;&lt;title&gt;pico+ 1-month expiration notification!&lt;/title&gt;&#xA;&#x9;&#x9;&lt;style&gt;&#xA;&#x9;&#x9;&#x9;code {&#xA;&#x9;&#x9;&#x9;&#x9;background-color: #ddd;&#xA;&#x9;&#x9;&#x9;&#x9;border-radius: 5px;&#xA;&#x9;&#x9;&#x9;&#x9;padding: 1px 3px;&#xA;&#x9;&#x9;&#x9;}&#xA;&#x9;&#x9;&lt;/style&gt;&#xA;&#x9;&lt;/head&gt;&#xA;&#x9;&lt;body&gt;&#xA;&#x9;&#x9;&lt;h1&gt;pico+ 1-month expiration notification!&lt;/h1&gt;&#xA;&lt;p&gt;&#xA;&#x9;Your &lt;code&gt;pico+&lt;/code&gt; membership will expire on &lt;strong&gt;2021-08-16&lt;/strong&gt;.&#xA;&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&#x9;If your pico+ membership expires then we will:&#xA;&#xA;&#x9;&lt;ul&gt;&#xA;&#x9;&#x9;&lt;li&gt;revoke access to &lt;a href=&#34;https&#34;&gt;https://tuns.sh&lt;/a&gt;&lt;/li&gt;&#xA;&#x9;&#x9;&lt;li&gt;reject new sites being created for &lt;a href=&#34;https://pgs.sh&#34;&gt;pgs.sh&lt;/a&gt;&lt;/li&gt;&#xA;&#x9;&#x9;&lt;li&gt;revoke access to our IRC bouncer&lt;/li&gt;&#xA;&#x9;&lt;/ul&gt;&#xA;&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&#x9;In order to continue using our premium services, you need to purchase another year:&#xA;&#x9;&lt;a href=&#34;https://auth.pico.sh/checkout/user-a&#34;&gt;purchase pico+&lt;/a&gt;&#xA;&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&#x9;If you have any questions, please do not hesitate to &lt;a href=&#34;https://pico.sh/contact&#34;&gt;contact us&lt;/a&gt;.&#xA;&lt;/p&gt;&#xA;&#x9;&lt;/body&gt;&#xA;&lt;/html&gt;&#xA;</summary>
15+    <summary type="html">&#xA;&lt;html&gt;&#xA;&#x9;&lt;head&gt;&#xA;&#x9;&#x9;&lt;title&gt;pico+ 1-day expiration notification!&lt;/title&gt;&#xA;&#x9;&#x9;&lt;style&gt;&#xA;&#x9;&#x9;&#x9;code {&#xA;&#x9;&#x9;&#x9;&#x9;background-color: #ddd;&#xA;&#x9;&#x9;&#x9;&#x9;border-radius: 5px;&#xA;&#x9;&#x9;&#x9;&#x9;padding: 1px 3px;&#xA;&#x9;&#x9;&#x9;}&#xA;&#x9;&#x9;&lt;/style&gt;&#xA;&#x9;&lt;/head&gt;&#xA;&#x9;&lt;body&gt;&#xA;&#x9;&#x9;&lt;h1&gt;pico+ 1-day expiration notification!&lt;/h1&gt;&#xA;&lt;p&gt;&#xA;&#x9;Your &lt;code&gt;pico+&lt;/code&gt; membership will expire on &lt;strong&gt;2021-08-16&lt;/strong&gt;.&#xA;&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&#x9;If your pico+ membership expires then we will:&#xA;&#xA;&#x9;&lt;ul&gt;&#xA;&#x9;&#x9;&lt;li&gt;revoke access to &lt;a href=&#34;https&#34;&gt;https://tuns.sh&lt;/a&gt;&lt;/li&gt;&#xA;&#x9;&#x9;&lt;li&gt;reject new sites being created for &lt;a href=&#34;https://pgs.sh&#34;&gt;pgs.sh&lt;/a&gt;&lt;/li&gt;&#xA;&#x9;&#x9;&lt;li&gt;revoke access to our IRC bouncer&lt;/li&gt;&#xA;&#x9;&lt;/ul&gt;&#xA;&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&#x9;In order to continue using our premium services, you need to purchase another year:&#xA;&#x9;&lt;a href=&#34;https://auth.pico.sh/checkout/user-a&#34;&gt;purchase pico+&lt;/a&gt;&#xA;&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&#x9;If you have any questions, please do not hesitate to &lt;a href=&#34;https://pico.sh/contact&#34;&gt;contact us&lt;/a&gt;.&#xA;&lt;/p&gt;&#xA;&#x9;&lt;/body&gt;&#xA;&lt;/html&gt;&#xA;</summary>
16     <author>
17       <name>team pico</name>
18     </author>
19@@ -134,12 +134,12 @@ successfully added pico+ user
20     </author>
21   </entry>
22   <entry>
23-    <title>pico+ 1-day expiration notice</title>
24-    <updated>2021-08-14T14:30:45Z</updated>
25-    <id>1628951445</id>
26-    <content type="html">&#xA;&lt;html&gt;&#xA;&#x9;&lt;head&gt;&#xA;&#x9;&#x9;&lt;title&gt;pico+ 1-day expiration notification!&lt;/title&gt;&#xA;&#x9;&#x9;&lt;style&gt;&#xA;&#x9;&#x9;&#x9;code {&#xA;&#x9;&#x9;&#x9;&#x9;background-color: #ddd;&#xA;&#x9;&#x9;&#x9;&#x9;border-radius: 5px;&#xA;&#x9;&#x9;&#x9;&#x9;padding: 1px 3px;&#xA;&#x9;&#x9;&#x9;}&#xA;&#x9;&#x9;&lt;/style&gt;&#xA;&#x9;&lt;/head&gt;&#xA;&#x9;&lt;body&gt;&#xA;&#x9;&#x9;&lt;h1&gt;pico+ 1-day expiration notification!&lt;/h1&gt;&#xA;&lt;p&gt;&#xA;&#x9;Your &lt;code&gt;pico+&lt;/code&gt; membership will expire on &lt;strong&gt;2021-08-16&lt;/strong&gt;.&#xA;&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&#x9;If your pico+ membership expires then we will:&#xA;&#xA;&#x9;&lt;ul&gt;&#xA;&#x9;&#x9;&lt;li&gt;revoke access to &lt;a href=&#34;https&#34;&gt;https://tuns.sh&lt;/a&gt;&lt;/li&gt;&#xA;&#x9;&#x9;&lt;li&gt;reject new sites being created for &lt;a href=&#34;https://pgs.sh&#34;&gt;pgs.sh&lt;/a&gt;&lt;/li&gt;&#xA;&#x9;&#x9;&lt;li&gt;revoke access to our IRC bouncer&lt;/li&gt;&#xA;&#x9;&lt;/ul&gt;&#xA;&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&#x9;In order to continue using our premium services, you need to purchase another year:&#xA;&#x9;&lt;a href=&#34;https://auth.pico.sh/checkout/user-a&#34;&gt;purchase pico+&lt;/a&gt;&#xA;&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&#x9;If you have any questions, please do not hesitate to &lt;a href=&#34;https://pico.sh/contact&#34;&gt;contact us&lt;/a&gt;.&#xA;&lt;/p&gt;&#xA;&#x9;&lt;/body&gt;&#xA;&lt;/html&gt;&#xA;</content>
27+    <title>pico+ 1-month expiration notice</title>
28+    <updated>2021-07-16T14:30:45Z</updated>
29+    <id>1626445845</id>
30+    <content type="html">&#xA;&lt;html&gt;&#xA;&#x9;&lt;head&gt;&#xA;&#x9;&#x9;&lt;title&gt;pico+ 1-month expiration notification!&lt;/title&gt;&#xA;&#x9;&#x9;&lt;style&gt;&#xA;&#x9;&#x9;&#x9;code {&#xA;&#x9;&#x9;&#x9;&#x9;background-color: #ddd;&#xA;&#x9;&#x9;&#x9;&#x9;border-radius: 5px;&#xA;&#x9;&#x9;&#x9;&#x9;padding: 1px 3px;&#xA;&#x9;&#x9;&#x9;}&#xA;&#x9;&#x9;&lt;/style&gt;&#xA;&#x9;&lt;/head&gt;&#xA;&#x9;&lt;body&gt;&#xA;&#x9;&#x9;&lt;h1&gt;pico+ 1-month expiration notification!&lt;/h1&gt;&#xA;&lt;p&gt;&#xA;&#x9;Your &lt;code&gt;pico+&lt;/code&gt; membership will expire on &lt;strong&gt;2021-08-16&lt;/strong&gt;.&#xA;&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&#x9;If your pico+ membership expires then we will:&#xA;&#xA;&#x9;&lt;ul&gt;&#xA;&#x9;&#x9;&lt;li&gt;revoke access to &lt;a href=&#34;https&#34;&gt;https://tuns.sh&lt;/a&gt;&lt;/li&gt;&#xA;&#x9;&#x9;&lt;li&gt;reject new sites being created for &lt;a href=&#34;https://pgs.sh&#34;&gt;pgs.sh&lt;/a&gt;&lt;/li&gt;&#xA;&#x9;&#x9;&lt;li&gt;revoke access to our IRC bouncer&lt;/li&gt;&#xA;&#x9;&lt;/ul&gt;&#xA;&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&#x9;In order to continue using our premium services, you need to purchase another year:&#xA;&#x9;&lt;a href=&#34;https://auth.pico.sh/checkout/user-a&#34;&gt;purchase pico+&lt;/a&gt;&#xA;&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&#x9;If you have any questions, please do not hesitate to &lt;a href=&#34;https://pico.sh/contact&#34;&gt;contact us&lt;/a&gt;.&#xA;&lt;/p&gt;&#xA;&#x9;&lt;/body&gt;&#xA;&lt;/html&gt;&#xA;</content>
31     <link href="https://pico.sh" rel="alternate"></link>
32-    <summary type="html">&#xA;&lt;html&gt;&#xA;&#x9;&lt;head&gt;&#xA;&#x9;&#x9;&lt;title&gt;pico+ 1-day expiration notification!&lt;/title&gt;&#xA;&#x9;&#x9;&lt;style&gt;&#xA;&#x9;&#x9;&#x9;code {&#xA;&#x9;&#x9;&#x9;&#x9;background-color: #ddd;&#xA;&#x9;&#x9;&#x9;&#x9;border-radius: 5px;&#xA;&#x9;&#x9;&#x9;&#x9;padding: 1px 3px;&#xA;&#x9;&#x9;&#x9;}&#xA;&#x9;&#x9;&lt;/style&gt;&#xA;&#x9;&lt;/head&gt;&#xA;&#x9;&lt;body&gt;&#xA;&#x9;&#x9;&lt;h1&gt;pico+ 1-day expiration notification!&lt;/h1&gt;&#xA;&lt;p&gt;&#xA;&#x9;Your &lt;code&gt;pico+&lt;/code&gt; membership will expire on &lt;strong&gt;2021-08-16&lt;/strong&gt;.&#xA;&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&#x9;If your pico+ membership expires then we will:&#xA;&#xA;&#x9;&lt;ul&gt;&#xA;&#x9;&#x9;&lt;li&gt;revoke access to &lt;a href=&#34;https&#34;&gt;https://tuns.sh&lt;/a&gt;&lt;/li&gt;&#xA;&#x9;&#x9;&lt;li&gt;reject new sites being created for &lt;a href=&#34;https://pgs.sh&#34;&gt;pgs.sh&lt;/a&gt;&lt;/li&gt;&#xA;&#x9;&#x9;&lt;li&gt;revoke access to our IRC bouncer&lt;/li&gt;&#xA;&#x9;&lt;/ul&gt;&#xA;&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&#x9;In order to continue using our premium services, you need to purchase another year:&#xA;&#x9;&lt;a href=&#34;https://auth.pico.sh/checkout/user-a&#34;&gt;purchase pico+&lt;/a&gt;&#xA;&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&#x9;If you have any questions, please do not hesitate to &lt;a href=&#34;https://pico.sh/contact&#34;&gt;contact us&lt;/a&gt;.&#xA;&lt;/p&gt;&#xA;&#x9;&lt;/body&gt;&#xA;&lt;/html&gt;&#xA;</summary>
33+    <summary type="html">&#xA;&lt;html&gt;&#xA;&#x9;&lt;head&gt;&#xA;&#x9;&#x9;&lt;title&gt;pico+ 1-month expiration notification!&lt;/title&gt;&#xA;&#x9;&#x9;&lt;style&gt;&#xA;&#x9;&#x9;&#x9;code {&#xA;&#x9;&#x9;&#x9;&#x9;background-color: #ddd;&#xA;&#x9;&#x9;&#x9;&#x9;border-radius: 5px;&#xA;&#x9;&#x9;&#x9;&#x9;padding: 1px 3px;&#xA;&#x9;&#x9;&#x9;}&#xA;&#x9;&#x9;&lt;/style&gt;&#xA;&#x9;&lt;/head&gt;&#xA;&#x9;&lt;body&gt;&#xA;&#x9;&#x9;&lt;h1&gt;pico+ 1-month expiration notification!&lt;/h1&gt;&#xA;&lt;p&gt;&#xA;&#x9;Your &lt;code&gt;pico+&lt;/code&gt; membership will expire on &lt;strong&gt;2021-08-16&lt;/strong&gt;.&#xA;&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&#x9;If your pico+ membership expires then we will:&#xA;&#xA;&#x9;&lt;ul&gt;&#xA;&#x9;&#x9;&lt;li&gt;revoke access to &lt;a href=&#34;https&#34;&gt;https://tuns.sh&lt;/a&gt;&lt;/li&gt;&#xA;&#x9;&#x9;&lt;li&gt;reject new sites being created for &lt;a href=&#34;https://pgs.sh&#34;&gt;pgs.sh&lt;/a&gt;&lt;/li&gt;&#xA;&#x9;&#x9;&lt;li&gt;revoke access to our IRC bouncer&lt;/li&gt;&#xA;&#x9;&lt;/ul&gt;&#xA;&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&#x9;In order to continue using our premium services, you need to purchase another year:&#xA;&#x9;&lt;a href=&#34;https://auth.pico.sh/checkout/user-a&#34;&gt;purchase pico+&lt;/a&gt;&#xA;&lt;/p&gt;&#xA;&lt;p&gt;&#xA;&#x9;If you have any questions, please do not hesitate to &lt;a href=&#34;https://pico.sh/contact&#34;&gt;contact us&lt;/a&gt;.&#xA;&lt;/p&gt;&#xA;&#x9;&lt;/body&gt;&#xA;&lt;/html&gt;&#xA;</summary>
34     <author>
35       <name>team pico</name>
36     </author>
M pkg/apps/auth/api.go
+36, -0
 1@@ -20,6 +20,7 @@ import (
 2 	"github.com/picosh/pico/pkg/db/postgres"
 3 	"github.com/picosh/pico/pkg/shared"
 4 	"github.com/picosh/utils"
 5+	"github.com/picosh/utils/pipe"
 6 	"github.com/picosh/utils/pipe/metrics"
 7 	"github.com/prometheus/client_golang/prometheus/promhttp"
 8 )
 9@@ -723,6 +724,39 @@ func metricDrainSub(ctx context.Context, dbpool db.DB, logger *slog.Logger, secr
10 	}
11 }
12 
13+func tunsEventLogDrainSub(ctx context.Context, dbpool db.DB, logger *slog.Logger, secret string) {
14+	drain := pipe.NewReconnectReadWriteCloser(
15+		ctx,
16+		logger,
17+		shared.NewPicoPipeClient(),
18+		"tuns-event-drain-sub",
19+		"sub tuns-event-drain -k",
20+		100,
21+		10*time.Millisecond,
22+	)
23+
24+	for {
25+		scanner := bufio.NewScanner(drain)
26+		scanner.Buffer(make([]byte, 32*1024), 32*1024)
27+		for scanner.Scan() {
28+			line := scanner.Text()
29+			clean := strings.TrimSpace(line)
30+			var log db.TunsEventLog
31+			err := json.Unmarshal([]byte(clean), &log)
32+			if err != nil {
33+				logger.Error("could not unmarshal line", "err", err)
34+				continue
35+			}
36+
37+			logger.Info("inserting tuns event log", "log", log)
38+			err = dbpool.InsertTunsEventLog(&log)
39+			if err != nil {
40+				logger.Error("could not insert tuns event log", "err", err)
41+			}
42+		}
43+	}
44+}
45+
46 func authMux(apiConfig *shared.ApiConfig) *http.ServeMux {
47 	serverRoot, err := fs.Sub(embedFS, "public")
48 	if err != nil {
49@@ -792,6 +826,8 @@ func StartApiServer() {
50 
51 	// gather metrics in the auth service
52 	go metricDrainSub(ctx, db, logger, cfg.Secret)
53+	// gather connect/disconnect logs from tuns
54+	go tunsEventLogDrainSub(ctx, db, logger, cfg.Secret)
55 
56 	defer ctx.Done()
57 
M pkg/db/db.go
+18, -0
 1@@ -323,6 +323,20 @@ type UserServiceStats struct {
 2 	LatestUpdatedAt  time.Time
 3 }
 4 
 5+type TunsEventLog struct {
 6+	ID             string     `json:"id"`
 7+	ServerID       string     `json:"server_id"`
 8+	Time           *time.Time `json:"time"`
 9+	User           string     `json:"user"`
10+	UserId         string     `json:"user_id"`
11+	RemoteAddr     string     `json:"remote_addr"`
12+	EventType      string     `json:"event_type"`
13+	TunnelType     string     `json:"tunnel_type"`
14+	ConnectionType string     `json:"connection_type"`
15+	TunnelAddrs    []string   `json:"tunnel_addrs"`
16+	CreatedAt      *time.Time `json:"created_at"`
17+}
18+
19 var NameValidator = regexp.MustCompile("^[a-zA-Z0-9]{1,50}$")
20 var DenyList = []string{
21 	"admin",
22@@ -415,5 +429,9 @@ type DB interface {
23 
24 	FindUserStats(userID string) (*UserStats, error)
25 
26+	InsertTunsEventLog(log *TunsEventLog) error
27+	FindTunsEventLogs(userID string) ([]*TunsEventLog, error)
28+	FindTunsEventLogsByAddr(userID, addr string) ([]*TunsEventLog, error)
29+
30 	Close() error
31 }
M pkg/db/postgres/storage.go
+72, -1
 1@@ -12,7 +12,7 @@ import (
 2 
 3 	"slices"
 4 
 5-	_ "github.com/lib/pq"
 6+	"github.com/lib/pq"
 7 	"github.com/picosh/pico/pkg/db"
 8 	"github.com/picosh/utils"
 9 )
10@@ -1796,6 +1796,77 @@ func (me *PsqlDB) findPagesStats(userID string) (*db.UserServiceStats, error) {
11 	return &stats, nil
12 }
13 
14+func (me *PsqlDB) InsertTunsEventLog(log *db.TunsEventLog) error {
15+	_, err := me.Db.Exec(
16+		`INSERT INTO tuns_event_logs
17+			(user_id, server_id, remote_addr, event_type, tunnel_type, connection_type, tunnel_addrs)
18+		VALUES
19+			($1, $2, $3, $4, $5, $6, $7)`,
20+		log.UserId, log.ServerID, log.RemoteAddr, log.EventType, log.TunnelType,
21+		log.ConnectionType, pq.Array(log.TunnelAddrs),
22+	)
23+	return err
24+}
25+
26+func (me *PsqlDB) FindTunsEventLogsByAddr(userID, addr string) ([]*db.TunsEventLog, error) {
27+	logs := []*db.TunsEventLog{}
28+	fmt.Println(addr)
29+	rs, err := me.Db.Query(
30+		`SELECT id, user_id, server_id, remote_addr, event_type, tunnel_type, connection_type, tunnel_addrs, created_at
31+		FROM tuns_event_logs WHERE user_id=$1 AND tunnel_addrs @> ARRAY[$2] ORDER BY created_at DESC`, userID, addr)
32+	if err != nil {
33+		return nil, err
34+	}
35+
36+	for rs.Next() {
37+		log := db.TunsEventLog{}
38+		err := rs.Scan(
39+			&log.ID, &log.UserId, &log.ServerID, &log.RemoteAddr,
40+			&log.EventType, &log.TunnelType, &log.ConnectionType,
41+			(*pq.StringArray)(&log.TunnelAddrs), &log.CreatedAt,
42+		)
43+		if err != nil {
44+			return nil, err
45+		}
46+		logs = append(logs, &log)
47+	}
48+
49+	if rs.Err() != nil {
50+		return nil, rs.Err()
51+	}
52+
53+	return logs, nil
54+}
55+
56+func (me *PsqlDB) FindTunsEventLogs(userID string) ([]*db.TunsEventLog, error) {
57+	logs := []*db.TunsEventLog{}
58+	rs, err := me.Db.Query(
59+		`SELECT id, user_id, server_id, remote_addr, event_type, tunnel_type, connection_type, tunnel_addrs, created_at
60+		FROM tuns_event_logs WHERE user_id=$1 ORDER BY created_at DESC`, userID)
61+	if err != nil {
62+		return nil, err
63+	}
64+
65+	for rs.Next() {
66+		log := db.TunsEventLog{}
67+		err := rs.Scan(
68+			&log.ID, &log.UserId, &log.ServerID, &log.RemoteAddr,
69+			&log.EventType, &log.TunnelType, &log.ConnectionType,
70+			(*pq.StringArray)(&log.TunnelAddrs), &log.CreatedAt,
71+		)
72+		if err != nil {
73+			return nil, err
74+		}
75+		logs = append(logs, &log)
76+	}
77+
78+	if rs.Err() != nil {
79+		return nil, rs.Err()
80+	}
81+
82+	return logs, nil
83+}
84+
85 func (me *PsqlDB) FindUserStats(userID string) (*db.UserStats, error) {
86 	stats := db.UserStats{}
87 	rs, err := me.Db.Query(`SELECT cur_space, count(id), min(created_at), max(created_at), max(updated_at) FROM posts WHERE user_id=$1 GROUP BY cur_space`, userID)
M pkg/db/stub/stub.go
+12, -0
 1@@ -268,3 +268,15 @@ func (me *StubDB) FindTagsForUser(userID string, tag string) ([]string, error) {
 2 func (me *StubDB) FindUserStats(userID string) (*db.UserStats, error) {
 3 	return nil, notImpl
 4 }
 5+
 6+func (me *StubDB) InsertTunsEventLog(log *db.TunsEventLog) error {
 7+	return notImpl
 8+}
 9+
10+func (me *StubDB) FindTunsEventLogsByAddr(userID, addr string) ([]*db.TunsEventLog, error) {
11+	return nil, notImpl
12+}
13+
14+func (me *StubDB) FindTunsEventLogs(userID string) ([]*db.TunsEventLog, error) {
15+	return nil, notImpl
16+}
M pkg/shared/feed.go
+34, -0
 1@@ -2,6 +2,8 @@ package shared
 2 
 3 import (
 4 	"fmt"
 5+	"sort"
 6+	"strings"
 7 	"time"
 8 
 9 	"github.com/gorilla/feeds"
10@@ -128,6 +130,38 @@ func UserFeed(me db.DB, user *db.User, token string) (*feeds.Feed, error) {
11 		}
12 	}
13 
14+	tunsLogs, _ := me.FindTunsEventLogs(user.ID)
15+	for _, eventLog := range tunsLogs {
16+		content := fmt.Sprintf(`Created At: %s
17+Event type: %s
18+Connection type: %s
19+Remote addr: %s
20+Tunnel type: %s
21+Tunnel addrs: %s
22+Server: %s`,
23+			eventLog.CreatedAt.Format(time.RFC3339), eventLog.EventType, eventLog.ConnectionType,
24+			eventLog.RemoteAddr, eventLog.TunnelType, eventLog.TunnelAddrs, eventLog.ServerID,
25+		)
26+		logItem := &feeds.Item{
27+			Id: fmt.Sprintf("%d", eventLog.CreatedAt.Unix()),
28+			Title: fmt.Sprintf(
29+				"%s tuns event for %s",
30+				eventLog.EventType, strings.Join(eventLog.TunnelAddrs, ", "),
31+			),
32+			Link:        &feeds.Link{Href: "https://pico.sh"},
33+			Content:     content,
34+			Created:     *eventLog.CreatedAt,
35+			Updated:     *eventLog.CreatedAt,
36+			Description: content,
37+			Author:      &feeds.Author{Name: "team pico"},
38+		}
39+		feedItems = append(feedItems, logItem)
40+	}
41+
42+	sort.Slice(feedItems, func(i, j int) bool {
43+		return feedItems[i].Created.After(feedItems[j].Created)
44+	})
45+
46 	feed.Items = feedItems
47 	return feed, nil
48 }
M pkg/tui/tuns.go
+81, -23
  1@@ -15,6 +15,7 @@ import (
  2 	"git.sr.ht/~rockorager/vaxis/vxfw/list"
  3 	"git.sr.ht/~rockorager/vaxis/vxfw/richtext"
  4 	"git.sr.ht/~rockorager/vaxis/vxfw/text"
  5+	"github.com/picosh/pico/pkg/db"
  6 	"github.com/picosh/pico/pkg/shared"
  7 	"github.com/picosh/utils/pipe"
  8 )
  9@@ -50,16 +51,15 @@ type TunsClientSimple struct {
 10 }
 11 
 12 type ResultLog struct {
 13-	ServerID        string    `json:"server_id"`
 14-	User            string    `json:"user"`
 15-	UserId          string    `json:"user_id"`
 16-	CurrentTime     string    `json:"current_time"`
 17-	StartTime       time.Time `json:"start_time"`
 18-	StartTimePretty string    `json:"start_time_pretty"`
 19-	RequestTime     string    `json:"request_time"`
 20-	RequestIP       string    `json:"request_ip"`
 21-	RequestMethod   string    `json:"request_method"`
 22-	// RequestURL         string              `json:"request_url"`
 23+	ServerID           string              `json:"server_id"`
 24+	User               string              `json:"user"`
 25+	UserId             string              `json:"user_id"`
 26+	CurrentTime        string              `json:"current_time"`
 27+	StartTime          time.Time           `json:"start_time"`
 28+	StartTimePretty    string              `json:"start_time_pretty"`
 29+	RequestTime        string              `json:"request_time"`
 30+	RequestIP          string              `json:"request_ip"`
 31+	RequestMethod      string              `json:"request_method"`
 32 	OriginalRequestURI string              `json:"original_request_uri"`
 33 	RequestHeaders     map[string][]string `json:"request_headers"`
 34 	RequestBody        string              `json:"request_body"`
 35@@ -70,6 +70,7 @@ type ResultLog struct {
 36 	TunnelType         string              `json:"tunnel_type"`
 37 	ConnectionType     string              `json:"connection_type"`
 38 	TunnelAddrs        []string            `json:"tunnel_addrs"`
 39+	// RequestURL         string              `json:"request_url"`
 40 }
 41 
 42 type ResultLogLineLoaded struct {
 43@@ -78,21 +79,25 @@ type ResultLogLineLoaded struct {
 44 
 45 type TunsLoaded struct{}
 46 
 47+type EventLogsLoaded struct{}
 48+
 49 type TunsPage struct {
 50 	shared *SharedModel
 51 
 52-	loading   bool
 53-	err       error
 54-	tuns      []TunsClientSimple
 55-	selected  string
 56-	focus     string
 57-	leftPane  list.Dynamic
 58-	rightPane *Pager
 59-	logs      []*ResultLog
 60-	logList   list.Dynamic
 61-	ctx       context.Context
 62-	done      context.CancelFunc
 63-	isAdmin   bool
 64+	loading      bool
 65+	err          error
 66+	tuns         []TunsClientSimple
 67+	selected     string
 68+	focus        string
 69+	leftPane     list.Dynamic
 70+	rightPane    *Pager
 71+	logs         []*ResultLog
 72+	logList      list.Dynamic
 73+	ctx          context.Context
 74+	done         context.CancelFunc
 75+	isAdmin      bool
 76+	eventLogs    []*db.TunsEventLog
 77+	eventLogList list.Dynamic
 78 }
 79 
 80 func NewTunsPage(shrd *SharedModel) *TunsPage {
 81@@ -103,6 +108,7 @@ func NewTunsPage(shrd *SharedModel) *TunsPage {
 82 	}
 83 	m.leftPane = list.Dynamic{DrawCursor: true, Builder: m.getLeftWidget}
 84 	m.logList = list.Dynamic{DrawCursor: true, Builder: m.getLogWidget}
 85+	m.eventLogList = list.Dynamic{DrawCursor: true, Builder: m.getEventLogWidget}
 86 	ff, _ := shrd.Dbpool.FindFeatureForUser(m.shared.User.ID, "admin")
 87 	if ff != nil {
 88 		m.isAdmin = true
 89@@ -145,6 +151,25 @@ func (m *TunsPage) getLogWidget(i uint, cursor uint) vxfw.Widget {
 90 	return txt
 91 }
 92 
 93+func (m *TunsPage) getEventLogWidget(i uint, cursor uint) vxfw.Widget {
 94+	if int(i) >= len(m.eventLogs) {
 95+		return nil
 96+	}
 97+
 98+	log := m.eventLogs[i]
 99+	style := vaxis.Style{Foreground: green}
100+	if log.EventType == "disconnect" {
101+		style = vaxis.Style{Foreground: red}
102+	}
103+	txt := richtext.New([]vaxis.Segment{
104+		{Text: log.CreatedAt.Format(time.RFC3339) + " "},
105+		{Text: log.EventType + " ", Style: style},
106+		{Text: log.RemoteAddr},
107+	})
108+	txt.Softwrap = false
109+	return txt
110+}
111+
112 func (m *TunsPage) connectToLogs() error {
113 	ctx, cancel := context.WithCancel(m.shared.Session.Context())
114 	m.ctx = ctx
115@@ -208,6 +233,8 @@ func (m *TunsPage) HandleEvent(ev vaxis.Event, ph vxfw.EventPhase) (vxfw.Command
116 			m.logList.SetCursor(uint(len(m.logs) - 1))
117 		}
118 		return vxfw.RedrawCmd{}, nil
119+	case EventLogsLoaded:
120+		return vxfw.RedrawCmd{}, nil
121 	case TunsLoaded:
122 		m.focus = "tuns"
123 		return vxfw.BatchCmd([]vxfw.Command{
124@@ -218,6 +245,8 @@ func (m *TunsPage) HandleEvent(ev vaxis.Event, ph vxfw.EventPhase) (vxfw.Command
125 		if msg.Matches(vaxis.KeyEnter) {
126 			m.selected = m.tuns[m.leftPane.Cursor()].TunAddress
127 			m.logs = []*ResultLog{}
128+			m.eventLogs = []*db.TunsEventLog{}
129+			go m.fetchEventLogs()
130 			return vxfw.RedrawCmd{}, nil
131 		}
132 		if msg.Matches(vaxis.KeyTab) {
133@@ -325,7 +354,20 @@ func (m *TunsPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
134 			Characters: vaxis.Characters,
135 			Max: vxfw.Size{
136 				Width:  uint16(rightPaneW) - 4,
137-				Height: ctx.Max.Height - uint16(ah) - 3,
138+				Height: 15,
139+			},
140+		})
141+		rightSurf.AddChild(0, ah, surf)
142+		ah += int(surf.Size.Height)
143+
144+		brd = NewBorder(&m.eventLogList)
145+		brd.Label = "conn events"
146+		m.focusBorder(brd)
147+		surf, _ = brd.Draw(vxfw.DrawContext{
148+			Characters: vaxis.Characters,
149+			Max: vxfw.Size{
150+				Width:  uint16(rightPaneW) - 4,
151+				Height: 15,
152 			},
153 		})
154 		rightSurf.AddChild(0, ah, surf)
155@@ -376,6 +418,22 @@ func fetch(fqdn, auth string) (map[string]*TunsClient, error) {
156 	return data.Clients, nil
157 }
158 
159+func (m *TunsPage) fetchEventLogs() {
160+	site := m.findSelected()
161+	addr := m.selected
162+	if site.TunType == "http" {
163+		addr = "http://" + addr
164+	} else if site.TunType == "https" {
165+		addr = "https://" + addr
166+	}
167+	logs, err := m.shared.Dbpool.FindTunsEventLogsByAddr(m.shared.User.ID, addr)
168+	if err != nil {
169+		m.err = err
170+		return
171+	}
172+	m.eventLogs = logs
173+}
174+
175 func (m *TunsPage) fetchTuns() {
176 	tMap, err := fetch("tuns.sh", m.shared.Cfg.TunsSecret)
177 	if err != nil {
A sql/migrations/20250319_add_tuns_event_logs_table.sql
+17, -0
 1@@ -0,0 +1,17 @@
 2+CREATE TABLE IF NOT EXISTS tuns_event_logs (
 3+  id uuid NOT NULL DEFAULT uuid_generate_v4(),
 4+  user_id uuid NOT NULL,
 5+  server_id text NOT NULL,
 6+  remote_addr text NOT NULL,
 7+  event_type text NOT NULL,
 8+  tunnel_type text NOT NULL,
 9+  connection_type text NOT NULL,
10+  tunnel_addrs text[] NOT NULL,
11+  created_at timestamp without time zone NOT NULL DEFAULT NOW(),
12+  CONSTRAINT tuns_event_logs_pkey PRIMARY KEY (id),
13+  CONSTRAINT fk_tuns_event_logs_user
14+    FOREIGN KEY(user_id)
15+  REFERENCES app_users(id)
16+  ON DELETE CASCADE
17+  ON UPDATE CASCADE
18+);