Initial commit: stats-gateway gRPC service + dashboard + CLI
This commit is contained in:
@@ -0,0 +1 @@
|
||||
bin/
|
||||
@@ -0,0 +1,10 @@
|
||||
.PHONY: build test clean
|
||||
|
||||
build:
|
||||
go build -o bin/psp-stats-cli .
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
clean:
|
||||
rm -rf bin/
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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 => ..
|
||||
@@ -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=
|
||||
@@ -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.
|
||||
`)
|
||||
}
|
||||
Reference in New Issue
Block a user