Initial commit: stats-gateway gRPC service + dashboard + CLI

This commit is contained in:
2026-05-05 21:04:32 +05:00
commit 4059e94759
44 changed files with 2513 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
bin/
+10
View File
@@ -0,0 +1,10 @@
.PHONY: build test clean
build:
go build -o bin/psp-stats-cli .
test:
go test ./...
clean:
rm -rf bin/
+59
View File
@@ -0,0 +1,59 @@
# psp-stats-cli
CLI для запросов к PSP Speedtest Stats (https://stats.projectshitpost.fun/).
## Установка
```
go install github.com/bergabruh/psp-stats-cli@latest
```
или собрать из исходников:
```
git clone <repo>
cd psp-stats-cli && make build
./bin/psp-stats-cli help
```
## Команды
### recent
```
psp-stats-cli recent --node fl --limit 10
psp-stats-cli recent --since 2026-05-01T00:00:00Z
```
Показывает последние N samples в текстовом формате `timestamp node http=X.Xms tls=...`.
Флаги:
- `--node fl|pl1` — фильтр по узлу (пусто = оба)
- `--limit N` — максимум N записей (по умолчанию 20, макс 1000)
- `--since RFC3339` — нижняя граница по времени
- `--server host:port` — gRPC-эндпоинт (по умолчанию `stats.projectshitpost.fun:443`)
- `--timeout duration` — таймаут запроса (по умолчанию 10s)
### aggregate
```
psp-stats-cli aggregate --node pl1 --since 2026-05-01T00:00:00Z
```
Показывает гистограмму HTTP-latency по бакетам {25, 50, 100, 250, 500, 1000, 5000} ms.
Флаги:
- `--node fl|pl1` — фильтр по узлу (пусто = оба)
- `--since RFC3339` — нижняя граница (по умолчанию 24 часа назад)
- `--until RFC3339` — верхняя граница (по умолчанию сейчас)
- `--server host:port` — gRPC-эндпоинт
- `--timeout duration` — таймаут запроса (по умолчанию 10s)
## Сервер
По умолчанию `stats.projectshitpost.fun:443`. Override через `--server host:port`.
Используется TLS 1.3 + HTTP/2 + gRPC. Public методы аутентификации не требуют.
## API
См. [`stats-gateway` proto](https://gitea.projectshitpost.fun/bergabruh/stats-gateway).
+64
View File
@@ -0,0 +1,64 @@
package cmd
import (
"context"
"crypto/tls"
"flag"
"fmt"
"log"
"strings"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
statsv1 "github.com/bergabruh/stats-gateway/gen/stats/v1"
)
func Aggregate(args []string) {
fs := flag.NewFlagSet("aggregate", flag.ExitOnError)
server := fs.String("server", "stats.projectshitpost.fun:443", "gRPC endpoint host:port")
node := fs.String("node", "", "fl|pl1 (empty = both)")
since := fs.String("since", "", "RFC3339 lower bound (defaults to 24h ago)")
until := fs.String("until", "", "RFC3339 upper bound (defaults to now)")
timeout := fs.Duration("timeout", 10*time.Second, "")
fs.Parse(args)
if *since == "" {
// Default to 24h ago for convenience.
*since = time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339)
}
conn, err := grpc.NewClient(*server,
grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
if err != nil {
log.Fatalf("dial: %v", err)
}
defer conn.Close()
client := statsv1.NewSpeedStatusClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
hist, err := client.Aggregate(ctx, &statsv1.Range{
Node: *node,
Since: *since,
Until: *until,
})
if err != nil {
log.Fatalf("Aggregate: %v", err)
}
fmt.Printf("Histogram of HTTP latency (ms) — node=%q since=%s\n", *node, *since)
for _, b := range hist.Buckets {
bar := ""
if b.Count > 0 {
n := int(b.Count)
if n > 60 {
n = 60
}
bar = strings.Repeat("#", n)
}
fmt.Printf(" <= %6.0fms %5d %s\n", b.UpperMs, b.Count, bar)
}
}
+65
View File
@@ -0,0 +1,65 @@
package cmd
import (
"context"
"crypto/tls"
"flag"
"fmt"
"io"
"log"
"os"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
statsv1 "github.com/bergabruh/stats-gateway/gen/stats/v1"
)
func Recent(args []string) {
fs := flag.NewFlagSet("recent", flag.ExitOnError)
server := fs.String("server", "stats.projectshitpost.fun:443", "gRPC endpoint host:port")
node := fs.String("node", "", "fl|pl1 (empty = both)")
limit := fs.Int("limit", 20, "max samples (1..1000)")
since := fs.String("since", "", "RFC3339 lower bound (optional)")
timeout := fs.Duration("timeout", 10*time.Second, "")
fs.Parse(args)
conn, err := grpc.NewClient(*server,
grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
if err != nil {
log.Fatalf("dial: %v", err)
}
defer conn.Close()
client := statsv1.NewSpeedStatusClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
stream, err := client.RecentResults(ctx, &statsv1.Filter{
Node: *node,
Limit: int32(*limit),
Since: *since,
})
if err != nil {
log.Fatalf("RecentResults: %v", err)
}
count := 0
for {
s, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
fmt.Fprintf(os.Stderr, "recv: %v\n", err)
os.Exit(1)
}
fmt.Printf("%s %-3s http=%6.1fms tls=%6.1fms tcp=%6.1fms dns=%5.1fms\n",
s.Timestamp, s.Node, s.HttpLatencyMs, s.TlsHandshakeMs, s.TcpRttMs, s.DnsResolveMs)
count++
}
if count == 0 {
fmt.Fprintln(os.Stderr, "(no samples)")
}
}
+18
View File
@@ -0,0 +1,18 @@
module github.com/bergabruh/psp-stats-cli
go 1.25.0
require (
github.com/bergabruh/stats-gateway v0.0.0
google.golang.org/grpc v1.81.0
)
require (
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
replace github.com/bergabruh/stats-gateway => ..
+38
View File
@@ -0,0 +1,38 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"fmt"
"os"
"github.com/bergabruh/psp-stats-cli/cmd"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
switch os.Args[1] {
case "recent":
cmd.Recent(os.Args[2:])
case "aggregate":
cmd.Aggregate(os.Args[2:])
case "-h", "--help", "help":
usage()
case "version":
fmt.Println("psp-stats-cli 0.1.0")
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n\n", os.Args[1])
usage()
os.Exit(2)
}
}
func usage() {
fmt.Print(`psp-stats-cli — query PSP Speedtest Stats
Usage:
psp-stats-cli <command> [flags]
Commands:
recent Show last N probe samples
aggregate Show histogram for time range
version Show CLI version
help Show this help
Run 'psp-stats-cli <command> --help' for command-specific flags.
`)
}