Initial commit: stats-gateway gRPC service + dashboard + CLI
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
|||||||
|
bin/
|
||||||
|
*.exe
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
|
||||||
|
# experiment artifacts
|
||||||
|
data/snapshot.json
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 bergamot
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
.PHONY: build test clean
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build -o bin/stats-gateway ./cmd/server
|
||||||
|
go build -o bin/stats-collector ./cmd/collector
|
||||||
|
go build -o bin/stats-snapshot ./cmd/snapshot
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf bin/
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# stats-gateway
|
||||||
|
|
||||||
|
gRPC service collecting and publishing HTTP/TCP/DNS/TLS-handshake timing
|
||||||
|
metrics for two geo-distributed OpenSpeedTest nodes (FL — Finland, PL1 — Poland).
|
||||||
|
Powers the dashboard at <https://stats.projectshitpost.fun/>.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
- **`cmd/server`** — gRPC server (TLS terminated by nginx, listens on loopback).
|
||||||
|
- **`cmd/collector`** — periodic prober (HTTP HEAD with httptrace), runs every 5 min via systemd timer.
|
||||||
|
- **`cmd/snapshot`** — generates `snapshot.json` for the dashboard, runs every 10 min.
|
||||||
|
- **`internal/probes`** — timing logic (DNS, TCP, TLS handshake, HTTP).
|
||||||
|
- **`internal/storage`** — SQLite WAL with embedded migrations.
|
||||||
|
- **`internal/service`** — gRPC handlers (`RecentResults`, `Aggregate`, `Tunnel`).
|
||||||
|
- **`proto/`** — protobuf definitions; generated Go in `gen/`.
|
||||||
|
- **`stats-site/`** — static dashboard (vanilla JS + canvas charts, no framework deps).
|
||||||
|
- **`psp-stats-cli/`** — Go CLI for querying the API.
|
||||||
|
- **`systemd/`** — ops units for running the service + timers.
|
||||||
|
- **`docs/AUTH.md`** — auth doc for the `Tunnel` method (Bearer JWT/HS256, used by internal CI agents).
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
make build # ./bin/{stats-gateway,stats-collector,stats-snapshot}
|
||||||
|
make test
|
||||||
|
cd psp-stats-cli && make build
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
grpcurl -d '{"node":"fl","limit":5}' \
|
||||||
|
stats.projectshitpost.fun:443 stats.v1.SpeedStatus/RecentResults
|
||||||
|
|
||||||
|
See `proto/stats.proto`.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — see [LICENSE](./LICENSE).
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/bergabruh/stats-gateway/internal/probes"
|
||||||
|
"github.com/bergabruh/stats-gateway/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dbPath := flag.String("db", "/var/lib/stats-gateway/stats.db", "sqlite path")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
st, err := storage.Open(*dbPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
targets := []struct {
|
||||||
|
Node string
|
||||||
|
URL string
|
||||||
|
}{
|
||||||
|
{"fl", "https://fl.speedtest.projectshitpost.fun/"},
|
||||||
|
{"pl1", "https://speedtest.projectshitpost.fun/"},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
for _, t := range targets {
|
||||||
|
r, err := probes.ProbeHTTP(ctx, t.URL)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("probe %s: %v", t.Node, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := st.InsertSample(ctx, t.Node, r); err != nil {
|
||||||
|
log.Printf("store %s: %v", t.Node, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("probe %s ok: http=%.1fms tls=%.1fms",
|
||||||
|
t.Node, r.HTTPLatencyMs, r.TLSHandshakeMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
|
||||||
|
statsv1 "github.com/bergabruh/stats-gateway/gen/stats/v1"
|
||||||
|
"github.com/bergabruh/stats-gateway/internal/service"
|
||||||
|
"github.com/bergabruh/stats-gateway/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
addr := flag.String("addr", "127.0.0.1:50051", "listen address")
|
||||||
|
dbPath := flag.String("db", "/var/lib/stats-gateway/stats.db", "sqlite path")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
st, err := storage.Open(*dbPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("storage: %v", err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
lis, err := net.Listen("tcp", *addr)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := grpc.NewServer()
|
||||||
|
statsv1.RegisterSpeedStatusServer(srv, service.New(st))
|
||||||
|
|
||||||
|
log.Printf("stats-gateway listening on %s", *addr)
|
||||||
|
if err := srv.Serve(lis); err != nil {
|
||||||
|
log.Fatalf("serve: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/bergabruh/stats-gateway/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sample struct {
|
||||||
|
TS string `json:"ts"`
|
||||||
|
HTTPMs float64 `json:"http_ms"`
|
||||||
|
TLSMs float64 `json:"tls_ms"`
|
||||||
|
TCPMs float64 `json:"tcp_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type nodeData struct {
|
||||||
|
Recent []sample `json:"recent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type snapshot struct {
|
||||||
|
GeneratedAt string `json:"generated_at"`
|
||||||
|
Nodes map[string]nodeData `json:"nodes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dbPath := flag.String("db", "/var/lib/stats-gateway/stats.db", "")
|
||||||
|
out := flag.String("out", "/var/www/stats-site/data/snapshot.json", "")
|
||||||
|
limitPerNode := flag.Int("limit", 288, "max samples per node (288 = ~24h at 5min interval)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
st, err := storage.Open(*dbPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
s := snapshot{
|
||||||
|
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
Nodes: map[string]nodeData{},
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, node := range []string{"fl", "pl1"} {
|
||||||
|
samples, err := st.QueryRecent(ctx, node, *limitPerNode, time.Time{})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("query %s: %v", node, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nd := nodeData{Recent: make([]sample, 0, len(samples))}
|
||||||
|
for _, sm := range samples {
|
||||||
|
nd.Recent = append(nd.Recent, sample{
|
||||||
|
TS: sm.Timestamp.Format(time.RFC3339Nano),
|
||||||
|
HTTPMs: sm.HTTPLatencyMs,
|
||||||
|
TLSMs: sm.TLSHandshakeMs,
|
||||||
|
TCPMs: sm.TCPRttMs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
s.Nodes[node] = nd
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(*out), 0755); err != nil {
|
||||||
|
log.Fatalf("mkdir %s: %v", filepath.Dir(*out), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic write: write to .tmp then rename.
|
||||||
|
tmp := *out + ".tmp"
|
||||||
|
f, err := os.Create(tmp)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("create %s: %v", tmp, err)
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(f)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
if err := enc.Encode(s); err != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(tmp)
|
||||||
|
log.Fatalf("encode: %v", err)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
if err := os.Rename(tmp, *out); err != nil {
|
||||||
|
log.Fatalf("rename: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("snapshot: nodes=%d → %s", len(s.Nodes), *out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Auth для Tunnel-метода
|
||||||
|
|
||||||
|
Bearer JWT (HS256), HMAC signing secret в `/etc/stats-gateway/jwt.key`.
|
||||||
|
|
||||||
|
## Claims
|
||||||
|
|
||||||
|
- `sub` — client-id (`bergamot-laptop`, `bergamot-phone`, ...)
|
||||||
|
- `scope` — `tunnel` (только этот scope разрешён для Tunnel-метода)
|
||||||
|
- `iat`, `exp` — стандартные UNIX timestamps
|
||||||
|
- `jti` — random hex для аудита/revocation
|
||||||
|
|
||||||
|
## Выпуск нового токена
|
||||||
|
|
||||||
|
На gitea-узле от пользователя с доступом к `jwt.key`:
|
||||||
|
|
||||||
|
sudo -u stats /usr/local/bin/stats-issue-token --sub <client-id> --days <N>
|
||||||
|
|
||||||
|
(Helper-скрипт `issue-token.sh` устанавливается на сервер в Task 11 как Go-binary `stats-issue-token`.)
|
||||||
|
|
||||||
|
Локальный путь во время разработки — `experiments/grpc-tunnel/issue-token.sh`. Семантика идентична Go-варианту.
|
||||||
|
|
||||||
|
## Revocation
|
||||||
|
|
||||||
|
Опции (по убыванию агрессивности):
|
||||||
|
|
||||||
|
1. **Polite revocation** (один client) — добавить `jti` отзываемого токена в `/etc/stats-gateway/revoked.txt`
|
||||||
|
(формат: один jti на строку, `#` в начале строки — комментарий). Сервис подгружает на каждом запросе.
|
||||||
|
2. **Mass revocation** (все клиенты) — `openssl rand -hex 32 > /etc/stats-gateway/jwt.key`,
|
||||||
|
`systemctl restart stats-gateway`. Затем переиздать токены живым клиентам и раздать оффлайн.
|
||||||
|
|
||||||
|
Polite revocation реализуется в Task 11 (через `LoadRevokedFile`). Для старта — только rotation.
|
||||||
|
|
||||||
|
## Ротация secret
|
||||||
|
|
||||||
|
Раз в год минимум. Перед сменой:
|
||||||
|
1. Выпустить новые токены клиентам с новым ключом (НЕ deploy ещё на сервер)
|
||||||
|
2. Раздать токены оффлайн (мессенджер, не plain SMS)
|
||||||
|
3. Заменить ключ на сервере, restart
|
||||||
|
4. Старые токены немедленно невалидны
|
||||||
|
|
||||||
|
## Storage клиентского токена
|
||||||
|
|
||||||
|
На клиенте — `~/.psp/token` mode 600. CLI читает только этот файл (см. `psp-stats-cli connect --token-file`).
|
||||||
|
Не коммитить токены в репозитории, не передавать через email/Slack без e2e.
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// protoc-gen-go v1.36.11
|
||||||
|
// protoc v7.34.1
|
||||||
|
// source: stats.proto
|
||||||
|
|
||||||
|
package statsv1
|
||||||
|
|
||||||
|
import (
|
||||||
|
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||||
|
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||||
|
reflect "reflect"
|
||||||
|
sync "sync"
|
||||||
|
unsafe "unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Verify that this generated code is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||||
|
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Control message. First frame from client must be HELLO with target.
|
||||||
|
type Frame_Kind int32
|
||||||
|
|
||||||
|
const (
|
||||||
|
Frame_UNKNOWN Frame_Kind = 0
|
||||||
|
Frame_HELLO Frame_Kind = 1
|
||||||
|
Frame_DATA Frame_Kind = 2
|
||||||
|
Frame_CLOSE Frame_Kind = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// Enum value maps for Frame_Kind.
|
||||||
|
var (
|
||||||
|
Frame_Kind_name = map[int32]string{
|
||||||
|
0: "UNKNOWN",
|
||||||
|
1: "HELLO",
|
||||||
|
2: "DATA",
|
||||||
|
3: "CLOSE",
|
||||||
|
}
|
||||||
|
Frame_Kind_value = map[string]int32{
|
||||||
|
"UNKNOWN": 0,
|
||||||
|
"HELLO": 1,
|
||||||
|
"DATA": 2,
|
||||||
|
"CLOSE": 3,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (x Frame_Kind) Enum() *Frame_Kind {
|
||||||
|
p := new(Frame_Kind)
|
||||||
|
*p = x
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x Frame_Kind) String() string {
|
||||||
|
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Frame_Kind) Descriptor() protoreflect.EnumDescriptor {
|
||||||
|
return file_stats_proto_enumTypes[0].Descriptor()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Frame_Kind) Type() protoreflect.EnumType {
|
||||||
|
return &file_stats_proto_enumTypes[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x Frame_Kind) Number() protoreflect.EnumNumber {
|
||||||
|
return protoreflect.EnumNumber(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use Frame_Kind.Descriptor instead.
|
||||||
|
func (Frame_Kind) EnumDescriptor() ([]byte, []int) {
|
||||||
|
return file_stats_proto_rawDescGZIP(), []int{5, 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Filter struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Node string `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"` // "fl" | "pl1" | "" (all)
|
||||||
|
Limit int32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"` // 1..1000
|
||||||
|
Since string `protobuf:"bytes,3,opt,name=since,proto3" json:"since,omitempty"` // RFC3339, optional
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Filter) Reset() {
|
||||||
|
*x = Filter{}
|
||||||
|
mi := &file_stats_proto_msgTypes[0]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Filter) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Filter) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *Filter) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_stats_proto_msgTypes[0]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use Filter.ProtoReflect.Descriptor instead.
|
||||||
|
func (*Filter) Descriptor() ([]byte, []int) {
|
||||||
|
return file_stats_proto_rawDescGZIP(), []int{0}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Filter) GetNode() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Node
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Filter) GetLimit() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Limit
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Filter) GetSince() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Since
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type Sample struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Node string `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"`
|
||||||
|
Timestamp string `protobuf:"bytes,2,opt,name=timestamp,proto3" json:"timestamp,omitempty"` // RFC3339
|
||||||
|
HttpLatencyMs float64 `protobuf:"fixed64,3,opt,name=http_latency_ms,json=httpLatencyMs,proto3" json:"http_latency_ms,omitempty"`
|
||||||
|
TcpRttMs float64 `protobuf:"fixed64,4,opt,name=tcp_rtt_ms,json=tcpRttMs,proto3" json:"tcp_rtt_ms,omitempty"`
|
||||||
|
DnsResolveMs float64 `protobuf:"fixed64,5,opt,name=dns_resolve_ms,json=dnsResolveMs,proto3" json:"dns_resolve_ms,omitempty"`
|
||||||
|
TlsHandshakeMs float64 `protobuf:"fixed64,6,opt,name=tls_handshake_ms,json=tlsHandshakeMs,proto3" json:"tls_handshake_ms,omitempty"`
|
||||||
|
ThroughputKbps int64 `protobuf:"varint,7,opt,name=throughput_kbps,json=throughputKbps,proto3" json:"throughput_kbps,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Sample) Reset() {
|
||||||
|
*x = Sample{}
|
||||||
|
mi := &file_stats_proto_msgTypes[1]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Sample) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Sample) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *Sample) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_stats_proto_msgTypes[1]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use Sample.ProtoReflect.Descriptor instead.
|
||||||
|
func (*Sample) Descriptor() ([]byte, []int) {
|
||||||
|
return file_stats_proto_rawDescGZIP(), []int{1}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Sample) GetNode() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Node
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Sample) GetTimestamp() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Timestamp
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Sample) GetHttpLatencyMs() float64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.HttpLatencyMs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Sample) GetTcpRttMs() float64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.TcpRttMs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Sample) GetDnsResolveMs() float64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.DnsResolveMs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Sample) GetTlsHandshakeMs() float64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.TlsHandshakeMs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Sample) GetThroughputKbps() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.ThroughputKbps
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type Range struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Node string `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"`
|
||||||
|
Since string `protobuf:"bytes,2,opt,name=since,proto3" json:"since,omitempty"`
|
||||||
|
Until string `protobuf:"bytes,3,opt,name=until,proto3" json:"until,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Range) Reset() {
|
||||||
|
*x = Range{}
|
||||||
|
mi := &file_stats_proto_msgTypes[2]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Range) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Range) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *Range) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_stats_proto_msgTypes[2]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use Range.ProtoReflect.Descriptor instead.
|
||||||
|
func (*Range) Descriptor() ([]byte, []int) {
|
||||||
|
return file_stats_proto_rawDescGZIP(), []int{2}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Range) GetNode() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Node
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Range) GetSince() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Since
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Range) GetUntil() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Until
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type Histogram struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Buckets []*Bucket `protobuf:"bytes,1,rep,name=buckets,proto3" json:"buckets,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Histogram) Reset() {
|
||||||
|
*x = Histogram{}
|
||||||
|
mi := &file_stats_proto_msgTypes[3]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Histogram) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Histogram) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *Histogram) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_stats_proto_msgTypes[3]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use Histogram.ProtoReflect.Descriptor instead.
|
||||||
|
func (*Histogram) Descriptor() ([]byte, []int) {
|
||||||
|
return file_stats_proto_rawDescGZIP(), []int{3}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Histogram) GetBuckets() []*Bucket {
|
||||||
|
if x != nil {
|
||||||
|
return x.Buckets
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bucket struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
UpperMs float64 `protobuf:"fixed64,1,opt,name=upper_ms,json=upperMs,proto3" json:"upper_ms,omitempty"`
|
||||||
|
Count int64 `protobuf:"varint,2,opt,name=count,proto3" json:"count,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Bucket) Reset() {
|
||||||
|
*x = Bucket{}
|
||||||
|
mi := &file_stats_proto_msgTypes[4]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Bucket) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Bucket) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *Bucket) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_stats_proto_msgTypes[4]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use Bucket.ProtoReflect.Descriptor instead.
|
||||||
|
func (*Bucket) Descriptor() ([]byte, []int) {
|
||||||
|
return file_stats_proto_rawDescGZIP(), []int{4}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Bucket) GetUpperMs() float64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.UpperMs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Bucket) GetCount() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Count
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type Frame struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Kind Frame_Kind `protobuf:"varint,1,opt,name=kind,proto3,enum=stats.v1.Frame_Kind" json:"kind,omitempty"`
|
||||||
|
Target string `protobuf:"bytes,2,opt,name=target,proto3" json:"target,omitempty"` // "ip:port" — only used in HELLO
|
||||||
|
Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"` // raw TCP bytes
|
||||||
|
Padding []byte `protobuf:"bytes,4,opt,name=padding,proto3" json:"padding,omitempty"` // random padding для frame-length obfuscation
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Frame) Reset() {
|
||||||
|
*x = Frame{}
|
||||||
|
mi := &file_stats_proto_msgTypes[5]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Frame) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Frame) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *Frame) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_stats_proto_msgTypes[5]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use Frame.ProtoReflect.Descriptor instead.
|
||||||
|
func (*Frame) Descriptor() ([]byte, []int) {
|
||||||
|
return file_stats_proto_rawDescGZIP(), []int{5}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Frame) GetKind() Frame_Kind {
|
||||||
|
if x != nil {
|
||||||
|
return x.Kind
|
||||||
|
}
|
||||||
|
return Frame_UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Frame) GetTarget() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Target
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Frame) GetPayload() []byte {
|
||||||
|
if x != nil {
|
||||||
|
return x.Payload
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Frame) GetPadding() []byte {
|
||||||
|
if x != nil {
|
||||||
|
return x.Padding
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var File_stats_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
|
const file_stats_proto_rawDesc = "" +
|
||||||
|
"\n" +
|
||||||
|
"\vstats.proto\x12\bstats.v1\"H\n" +
|
||||||
|
"\x06Filter\x12\x12\n" +
|
||||||
|
"\x04node\x18\x01 \x01(\tR\x04node\x12\x14\n" +
|
||||||
|
"\x05limit\x18\x02 \x01(\x05R\x05limit\x12\x14\n" +
|
||||||
|
"\x05since\x18\x03 \x01(\tR\x05since\"\xf9\x01\n" +
|
||||||
|
"\x06Sample\x12\x12\n" +
|
||||||
|
"\x04node\x18\x01 \x01(\tR\x04node\x12\x1c\n" +
|
||||||
|
"\ttimestamp\x18\x02 \x01(\tR\ttimestamp\x12&\n" +
|
||||||
|
"\x0fhttp_latency_ms\x18\x03 \x01(\x01R\rhttpLatencyMs\x12\x1c\n" +
|
||||||
|
"\n" +
|
||||||
|
"tcp_rtt_ms\x18\x04 \x01(\x01R\btcpRttMs\x12$\n" +
|
||||||
|
"\x0edns_resolve_ms\x18\x05 \x01(\x01R\fdnsResolveMs\x12(\n" +
|
||||||
|
"\x10tls_handshake_ms\x18\x06 \x01(\x01R\x0etlsHandshakeMs\x12'\n" +
|
||||||
|
"\x0fthroughput_kbps\x18\a \x01(\x03R\x0ethroughputKbps\"G\n" +
|
||||||
|
"\x05Range\x12\x12\n" +
|
||||||
|
"\x04node\x18\x01 \x01(\tR\x04node\x12\x14\n" +
|
||||||
|
"\x05since\x18\x02 \x01(\tR\x05since\x12\x14\n" +
|
||||||
|
"\x05until\x18\x03 \x01(\tR\x05until\"7\n" +
|
||||||
|
"\tHistogram\x12*\n" +
|
||||||
|
"\abuckets\x18\x01 \x03(\v2\x10.stats.v1.BucketR\abuckets\"9\n" +
|
||||||
|
"\x06Bucket\x12\x19\n" +
|
||||||
|
"\bupper_ms\x18\x01 \x01(\x01R\aupperMs\x12\x14\n" +
|
||||||
|
"\x05count\x18\x02 \x01(\x03R\x05count\"\xb2\x01\n" +
|
||||||
|
"\x05Frame\x12(\n" +
|
||||||
|
"\x04kind\x18\x01 \x01(\x0e2\x14.stats.v1.Frame.KindR\x04kind\x12\x16\n" +
|
||||||
|
"\x06target\x18\x02 \x01(\tR\x06target\x12\x18\n" +
|
||||||
|
"\apayload\x18\x03 \x01(\fR\apayload\x12\x18\n" +
|
||||||
|
"\apadding\x18\x04 \x01(\fR\apadding\"3\n" +
|
||||||
|
"\x04Kind\x12\v\n" +
|
||||||
|
"\aUNKNOWN\x10\x00\x12\t\n" +
|
||||||
|
"\x05HELLO\x10\x01\x12\b\n" +
|
||||||
|
"\x04DATA\x10\x02\x12\t\n" +
|
||||||
|
"\x05CLOSE\x10\x032\xa7\x01\n" +
|
||||||
|
"\vSpeedStatus\x125\n" +
|
||||||
|
"\rRecentResults\x12\x10.stats.v1.Filter\x1a\x10.stats.v1.Sample0\x01\x121\n" +
|
||||||
|
"\tAggregate\x12\x0f.stats.v1.Range\x1a\x13.stats.v1.Histogram\x12.\n" +
|
||||||
|
"\x06Tunnel\x12\x0f.stats.v1.Frame\x1a\x0f.stats.v1.Frame(\x010\x01B9Z7github.com/bergabruh/stats-gateway/gen/stats/v1;statsv1b\x06proto3"
|
||||||
|
|
||||||
|
var (
|
||||||
|
file_stats_proto_rawDescOnce sync.Once
|
||||||
|
file_stats_proto_rawDescData []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
func file_stats_proto_rawDescGZIP() []byte {
|
||||||
|
file_stats_proto_rawDescOnce.Do(func() {
|
||||||
|
file_stats_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_stats_proto_rawDesc), len(file_stats_proto_rawDesc)))
|
||||||
|
})
|
||||||
|
return file_stats_proto_rawDescData
|
||||||
|
}
|
||||||
|
|
||||||
|
var file_stats_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
|
||||||
|
var file_stats_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
|
||||||
|
var file_stats_proto_goTypes = []any{
|
||||||
|
(Frame_Kind)(0), // 0: stats.v1.Frame.Kind
|
||||||
|
(*Filter)(nil), // 1: stats.v1.Filter
|
||||||
|
(*Sample)(nil), // 2: stats.v1.Sample
|
||||||
|
(*Range)(nil), // 3: stats.v1.Range
|
||||||
|
(*Histogram)(nil), // 4: stats.v1.Histogram
|
||||||
|
(*Bucket)(nil), // 5: stats.v1.Bucket
|
||||||
|
(*Frame)(nil), // 6: stats.v1.Frame
|
||||||
|
}
|
||||||
|
var file_stats_proto_depIdxs = []int32{
|
||||||
|
5, // 0: stats.v1.Histogram.buckets:type_name -> stats.v1.Bucket
|
||||||
|
0, // 1: stats.v1.Frame.kind:type_name -> stats.v1.Frame.Kind
|
||||||
|
1, // 2: stats.v1.SpeedStatus.RecentResults:input_type -> stats.v1.Filter
|
||||||
|
3, // 3: stats.v1.SpeedStatus.Aggregate:input_type -> stats.v1.Range
|
||||||
|
6, // 4: stats.v1.SpeedStatus.Tunnel:input_type -> stats.v1.Frame
|
||||||
|
2, // 5: stats.v1.SpeedStatus.RecentResults:output_type -> stats.v1.Sample
|
||||||
|
4, // 6: stats.v1.SpeedStatus.Aggregate:output_type -> stats.v1.Histogram
|
||||||
|
6, // 7: stats.v1.SpeedStatus.Tunnel:output_type -> stats.v1.Frame
|
||||||
|
5, // [5:8] is the sub-list for method output_type
|
||||||
|
2, // [2:5] is the sub-list for method input_type
|
||||||
|
2, // [2:2] is the sub-list for extension type_name
|
||||||
|
2, // [2:2] is the sub-list for extension extendee
|
||||||
|
0, // [0:2] is the sub-list for field type_name
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { file_stats_proto_init() }
|
||||||
|
func file_stats_proto_init() {
|
||||||
|
if File_stats_proto != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type x struct{}
|
||||||
|
out := protoimpl.TypeBuilder{
|
||||||
|
File: protoimpl.DescBuilder{
|
||||||
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_stats_proto_rawDesc), len(file_stats_proto_rawDesc)),
|
||||||
|
NumEnums: 1,
|
||||||
|
NumMessages: 6,
|
||||||
|
NumExtensions: 0,
|
||||||
|
NumServices: 1,
|
||||||
|
},
|
||||||
|
GoTypes: file_stats_proto_goTypes,
|
||||||
|
DependencyIndexes: file_stats_proto_depIdxs,
|
||||||
|
EnumInfos: file_stats_proto_enumTypes,
|
||||||
|
MessageInfos: file_stats_proto_msgTypes,
|
||||||
|
}.Build()
|
||||||
|
File_stats_proto = out.File
|
||||||
|
file_stats_proto_goTypes = nil
|
||||||
|
file_stats_proto_depIdxs = nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// - protoc-gen-go-grpc v1.6.1
|
||||||
|
// - protoc v7.34.1
|
||||||
|
// source: stats.proto
|
||||||
|
|
||||||
|
package statsv1
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
grpc "google.golang.org/grpc"
|
||||||
|
codes "google.golang.org/grpc/codes"
|
||||||
|
status "google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This is a compile-time assertion to ensure that this generated file
|
||||||
|
// is compatible with the grpc package it is being compiled against.
|
||||||
|
// Requires gRPC-Go v1.64.0 or later.
|
||||||
|
const _ = grpc.SupportPackageIsVersion9
|
||||||
|
|
||||||
|
const (
|
||||||
|
SpeedStatus_RecentResults_FullMethodName = "/stats.v1.SpeedStatus/RecentResults"
|
||||||
|
SpeedStatus_Aggregate_FullMethodName = "/stats.v1.SpeedStatus/Aggregate"
|
||||||
|
SpeedStatus_Tunnel_FullMethodName = "/stats.v1.SpeedStatus/Tunnel"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SpeedStatusClient is the client API for SpeedStatus service.
|
||||||
|
//
|
||||||
|
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||||
|
type SpeedStatusClient interface {
|
||||||
|
// RecentResults streams the last N samples for given filter.
|
||||||
|
RecentResults(ctx context.Context, in *Filter, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Sample], error)
|
||||||
|
// Aggregate returns histogram for a time range.
|
||||||
|
Aggregate(ctx context.Context, in *Range, opts ...grpc.CallOption) (*Histogram, error)
|
||||||
|
// Tunnel is a bidirectional stream for authenticated clients.
|
||||||
|
// Auth: Bearer JWT (HS256) в gRPC metadata "authorization: Bearer <jwt>".
|
||||||
|
// Server validates: signature, exp, scope=="tunnel", optional revocation list.
|
||||||
|
Tunnel(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[Frame, Frame], error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type speedStatusClient struct {
|
||||||
|
cc grpc.ClientConnInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSpeedStatusClient(cc grpc.ClientConnInterface) SpeedStatusClient {
|
||||||
|
return &speedStatusClient{cc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *speedStatusClient) RecentResults(ctx context.Context, in *Filter, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Sample], error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
stream, err := c.cc.NewStream(ctx, &SpeedStatus_ServiceDesc.Streams[0], SpeedStatus_RecentResults_FullMethodName, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
x := &grpc.GenericClientStream[Filter, Sample]{ClientStream: stream}
|
||||||
|
if err := x.ClientStream.SendMsg(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := x.ClientStream.CloseSend(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return x, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
|
type SpeedStatus_RecentResultsClient = grpc.ServerStreamingClient[Sample]
|
||||||
|
|
||||||
|
func (c *speedStatusClient) Aggregate(ctx context.Context, in *Range, opts ...grpc.CallOption) (*Histogram, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(Histogram)
|
||||||
|
err := c.cc.Invoke(ctx, SpeedStatus_Aggregate_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *speedStatusClient) Tunnel(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[Frame, Frame], error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
stream, err := c.cc.NewStream(ctx, &SpeedStatus_ServiceDesc.Streams[1], SpeedStatus_Tunnel_FullMethodName, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
x := &grpc.GenericClientStream[Frame, Frame]{ClientStream: stream}
|
||||||
|
return x, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
|
type SpeedStatus_TunnelClient = grpc.BidiStreamingClient[Frame, Frame]
|
||||||
|
|
||||||
|
// SpeedStatusServer is the server API for SpeedStatus service.
|
||||||
|
// All implementations must embed UnimplementedSpeedStatusServer
|
||||||
|
// for forward compatibility.
|
||||||
|
type SpeedStatusServer interface {
|
||||||
|
// RecentResults streams the last N samples for given filter.
|
||||||
|
RecentResults(*Filter, grpc.ServerStreamingServer[Sample]) error
|
||||||
|
// Aggregate returns histogram for a time range.
|
||||||
|
Aggregate(context.Context, *Range) (*Histogram, error)
|
||||||
|
// Tunnel is a bidirectional stream for authenticated clients.
|
||||||
|
// Auth: Bearer JWT (HS256) в gRPC metadata "authorization: Bearer <jwt>".
|
||||||
|
// Server validates: signature, exp, scope=="tunnel", optional revocation list.
|
||||||
|
Tunnel(grpc.BidiStreamingServer[Frame, Frame]) error
|
||||||
|
mustEmbedUnimplementedSpeedStatusServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnimplementedSpeedStatusServer must be embedded to have
|
||||||
|
// forward compatible implementations.
|
||||||
|
//
|
||||||
|
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||||
|
// pointer dereference when methods are called.
|
||||||
|
type UnimplementedSpeedStatusServer struct{}
|
||||||
|
|
||||||
|
func (UnimplementedSpeedStatusServer) RecentResults(*Filter, grpc.ServerStreamingServer[Sample]) error {
|
||||||
|
return status.Error(codes.Unimplemented, "method RecentResults not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedSpeedStatusServer) Aggregate(context.Context, *Range) (*Histogram, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method Aggregate not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedSpeedStatusServer) Tunnel(grpc.BidiStreamingServer[Frame, Frame]) error {
|
||||||
|
return status.Error(codes.Unimplemented, "method Tunnel not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedSpeedStatusServer) mustEmbedUnimplementedSpeedStatusServer() {}
|
||||||
|
func (UnimplementedSpeedStatusServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
|
// UnsafeSpeedStatusServer may be embedded to opt out of forward compatibility for this service.
|
||||||
|
// Use of this interface is not recommended, as added methods to SpeedStatusServer will
|
||||||
|
// result in compilation errors.
|
||||||
|
type UnsafeSpeedStatusServer interface {
|
||||||
|
mustEmbedUnimplementedSpeedStatusServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterSpeedStatusServer(s grpc.ServiceRegistrar, srv SpeedStatusServer) {
|
||||||
|
// If the following call panics, it indicates UnimplementedSpeedStatusServer was
|
||||||
|
// embedded by pointer and is nil. This will cause panics if an
|
||||||
|
// unimplemented method is ever invoked, so we test this at initialization
|
||||||
|
// time to prevent it from happening at runtime later due to I/O.
|
||||||
|
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||||
|
t.testEmbeddedByValue()
|
||||||
|
}
|
||||||
|
s.RegisterService(&SpeedStatus_ServiceDesc, srv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _SpeedStatus_RecentResults_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||||
|
m := new(Filter)
|
||||||
|
if err := stream.RecvMsg(m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return srv.(SpeedStatusServer).RecentResults(m, &grpc.GenericServerStream[Filter, Sample]{ServerStream: stream})
|
||||||
|
}
|
||||||
|
|
||||||
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
|
type SpeedStatus_RecentResultsServer = grpc.ServerStreamingServer[Sample]
|
||||||
|
|
||||||
|
func _SpeedStatus_Aggregate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(Range)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(SpeedStatusServer).Aggregate(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: SpeedStatus_Aggregate_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(SpeedStatusServer).Aggregate(ctx, req.(*Range))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _SpeedStatus_Tunnel_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||||
|
return srv.(SpeedStatusServer).Tunnel(&grpc.GenericServerStream[Frame, Frame]{ServerStream: stream})
|
||||||
|
}
|
||||||
|
|
||||||
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
|
type SpeedStatus_TunnelServer = grpc.BidiStreamingServer[Frame, Frame]
|
||||||
|
|
||||||
|
// SpeedStatus_ServiceDesc is the grpc.ServiceDesc for SpeedStatus service.
|
||||||
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
|
// and not to be introspected or modified (even as a copy)
|
||||||
|
var SpeedStatus_ServiceDesc = grpc.ServiceDesc{
|
||||||
|
ServiceName: "stats.v1.SpeedStatus",
|
||||||
|
HandlerType: (*SpeedStatusServer)(nil),
|
||||||
|
Methods: []grpc.MethodDesc{
|
||||||
|
{
|
||||||
|
MethodName: "Aggregate",
|
||||||
|
Handler: _SpeedStatus_Aggregate_Handler,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Streams: []grpc.StreamDesc{
|
||||||
|
{
|
||||||
|
StreamName: "RecentResults",
|
||||||
|
Handler: _SpeedStatus_RecentResults_Handler,
|
||||||
|
ServerStreams: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
StreamName: "Tunnel",
|
||||||
|
Handler: _SpeedStatus_Tunnel_Handler,
|
||||||
|
ServerStreams: true,
|
||||||
|
ClientStreams: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Metadata: "stats.proto",
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
module github.com/bergabruh/stats-gateway
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.44
|
||||||
|
google.golang.org/grpc v1.81.0
|
||||||
|
google.golang.org/protobuf v1.36.11
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||||
|
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,72 @@
|
|||||||
|
package probes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptrace"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Result holds timing measurements from a single HTTP probe.
|
||||||
|
type Result struct {
|
||||||
|
Node string // populated by storage on read; ignored by InsertSample (uses separate arg)
|
||||||
|
URL string
|
||||||
|
HTTPLatencyMs float64
|
||||||
|
TCPRttMs float64
|
||||||
|
DNSResolveMs float64
|
||||||
|
TLSHandshakeMs float64
|
||||||
|
ThroughputKbps int64
|
||||||
|
Timestamp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProbeHTTP performs an HTTP HEAD request to url, recording timing via httptrace.
|
||||||
|
func ProbeHTTP(ctx context.Context, url string) (*Result, error) {
|
||||||
|
r := &Result{URL: url, Timestamp: time.Now().UTC()}
|
||||||
|
|
||||||
|
var dnsStart, connStart, tlsStart time.Time
|
||||||
|
trace := &httptrace.ClientTrace{
|
||||||
|
DNSStart: func(_ httptrace.DNSStartInfo) { dnsStart = time.Now() },
|
||||||
|
DNSDone: func(_ httptrace.DNSDoneInfo) {
|
||||||
|
if !dnsStart.IsZero() {
|
||||||
|
r.DNSResolveMs = float64(time.Since(dnsStart).Microseconds()) / 1000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ConnectStart: func(_, _ string) { connStart = time.Now() },
|
||||||
|
ConnectDone: func(_, _ string, _ error) {
|
||||||
|
if !connStart.IsZero() {
|
||||||
|
r.TCPRttMs = float64(time.Since(connStart).Microseconds()) / 1000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TLSHandshakeStart: func() { tlsStart = time.Now() },
|
||||||
|
TLSHandshakeDone: func(_ tls.ConnectionState, _ error) {
|
||||||
|
if !tlsStart.IsZero() {
|
||||||
|
r.TLSHandshakeMs = float64(time.Since(tlsStart).Microseconds()) / 1000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(httptrace.WithClientTrace(ctx, trace), "HEAD", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("new request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("http: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
r.HTTPLatencyMs = float64(time.Since(start).Microseconds()) / 1000
|
||||||
|
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package probes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProbeHTTP_Success(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
r, err := ProbeHTTP(ctx, "https://example.com/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected success, got err: %v", err)
|
||||||
|
}
|
||||||
|
if r.HTTPLatencyMs <= 0 {
|
||||||
|
t.Errorf("expected positive latency, got %f", r.HTTPLatencyMs)
|
||||||
|
}
|
||||||
|
if r.TLSHandshakeMs <= 0 {
|
||||||
|
t.Errorf("expected positive TLS handshake time, got %f", r.TLSHandshakeMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeHTTP_Timeout(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := ProbeHTTP(ctx, "https://10.255.255.1/") // unreachable
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected timeout error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
statsv1 "github.com/bergabruh/stats-gateway/gen/stats/v1"
|
||||||
|
"github.com/bergabruh/stats-gateway/internal/storage"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service implements statsv1.SpeedStatusServer with public RecentResults
|
||||||
|
// and Aggregate methods. Tunnel returns Unimplemented until Task 11 wires
|
||||||
|
// auth + handler.
|
||||||
|
type Service struct {
|
||||||
|
statsv1.UnimplementedSpeedStatusServer
|
||||||
|
store *storage.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(st *storage.Store) *Service { return &Service{store: st} }
|
||||||
|
|
||||||
|
func (s *Service) RecentResults(f *statsv1.Filter, stream statsv1.SpeedStatus_RecentResultsServer) error {
|
||||||
|
limit := int(f.Limit)
|
||||||
|
if limit <= 0 || limit > 1000 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
var since time.Time
|
||||||
|
if f.Since != "" {
|
||||||
|
t, err := time.Parse(time.RFC3339, f.Since)
|
||||||
|
if err != nil {
|
||||||
|
return status.Errorf(codes.InvalidArgument, "since: %v", err)
|
||||||
|
}
|
||||||
|
since = t
|
||||||
|
}
|
||||||
|
samples, err := s.store.QueryRecent(stream.Context(), f.Node, limit, since)
|
||||||
|
if err != nil {
|
||||||
|
return status.Errorf(codes.Internal, "query: %v", err)
|
||||||
|
}
|
||||||
|
for _, r := range samples {
|
||||||
|
err := stream.Send(&statsv1.Sample{
|
||||||
|
Node: r.Node,
|
||||||
|
Timestamp: r.Timestamp.Format(time.RFC3339Nano),
|
||||||
|
HttpLatencyMs: r.HTTPLatencyMs,
|
||||||
|
TcpRttMs: r.TCPRttMs,
|
||||||
|
DnsResolveMs: r.DNSResolveMs,
|
||||||
|
TlsHandshakeMs: r.TLSHandshakeMs,
|
||||||
|
ThroughputKbps: r.ThroughputKbps,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// aggregateBounds defines bucket upper bounds (ms) used by Aggregate.
|
||||||
|
var aggregateBounds = []float64{25, 50, 100, 250, 500, 1000, 5000}
|
||||||
|
|
||||||
|
func (s *Service) Aggregate(ctx context.Context, r *statsv1.Range) (*statsv1.Histogram, error) {
|
||||||
|
since, _ := time.Parse(time.RFC3339, r.Since)
|
||||||
|
until, _ := time.Parse(time.RFC3339, r.Until)
|
||||||
|
if until.IsZero() {
|
||||||
|
until = time.Now().UTC()
|
||||||
|
}
|
||||||
|
if since.IsZero() {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "since required")
|
||||||
|
}
|
||||||
|
|
||||||
|
samples, err := s.store.QueryRange(ctx, r.Node, since, until)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "query: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
counts := make([]int64, len(aggregateBounds))
|
||||||
|
for _, sm := range samples {
|
||||||
|
for i, b := range aggregateBounds {
|
||||||
|
if sm.HTTPLatencyMs <= b {
|
||||||
|
counts[i]++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h := &statsv1.Histogram{}
|
||||||
|
for i, b := range aggregateBounds {
|
||||||
|
h.Buckets = append(h.Buckets, &statsv1.Bucket{UpperMs: b, Count: counts[i]})
|
||||||
|
}
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
"google.golang.org/grpc/test/bufconn"
|
||||||
|
|
||||||
|
statsv1 "github.com/bergabruh/stats-gateway/gen/stats/v1"
|
||||||
|
"github.com/bergabruh/stats-gateway/internal/probes"
|
||||||
|
"github.com/bergabruh/stats-gateway/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestServer(t *testing.T, st *storage.Store) (statsv1.SpeedStatusClient, func()) {
|
||||||
|
t.Helper()
|
||||||
|
lis := bufconn.Listen(1024 * 1024)
|
||||||
|
srv := grpc.NewServer()
|
||||||
|
statsv1.RegisterSpeedStatusServer(srv, New(st))
|
||||||
|
go srv.Serve(lis)
|
||||||
|
|
||||||
|
conn, err := grpc.NewClient("passthrough:///bufnet",
|
||||||
|
grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { return lis.Dial() }),
|
||||||
|
grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() { conn.Close(); srv.Stop() }
|
||||||
|
return statsv1.NewSpeedStatusClient(conn), cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecentResults_StreamsAllSamples(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, _ := storage.Open(filepath.Join(dir, "test.db"))
|
||||||
|
defer st.Close()
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
st.InsertSample(context.Background(), "fl", &probes.Result{
|
||||||
|
HTTPLatencyMs: float64(40 + i), Timestamp: time.Now().UTC().Add(time.Duration(i) * time.Second),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
client, cleanup := newTestServer(t, st)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
stream, err := client.RecentResults(context.Background(), &statsv1.Filter{Node: "fl", Limit: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for {
|
||||||
|
_, err := stream.Recv()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
if count != 3 {
|
||||||
|
t.Errorf("expected 3 samples, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAggregate_BucketCounts(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, _ := storage.Open(filepath.Join(dir, "test.db"))
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
// latencies [10, 20, 30, 50, 100, 200] -> buckets [<=25:2, <=50:2, <=100:1, <=250:1, ...]
|
||||||
|
latencies := []float64{10, 20, 30, 50, 100, 200}
|
||||||
|
for i, ms := range latencies {
|
||||||
|
st.InsertSample(context.Background(), "fl", &probes.Result{
|
||||||
|
HTTPLatencyMs: ms, Timestamp: now.Add(time.Duration(i) * time.Second),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
client, cleanup := newTestServer(t, st)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
hist, err := client.Aggregate(context.Background(), &statsv1.Range{
|
||||||
|
Node: "fl",
|
||||||
|
Since: now.Add(-time.Minute).Format(time.RFC3339),
|
||||||
|
Until: now.Add(time.Minute).Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find bucket counts by upper_ms
|
||||||
|
got := map[float64]int64{}
|
||||||
|
for _, b := range hist.Buckets {
|
||||||
|
got[b.UpperMs] = b.Count
|
||||||
|
}
|
||||||
|
want := map[float64]int64{25: 2, 50: 2, 100: 1, 250: 1, 500: 0, 1000: 0, 5000: 0}
|
||||||
|
for ms, want := range want {
|
||||||
|
if got[ms] != want {
|
||||||
|
t.Errorf("bucket <=%.0fms: got %d, want %d", ms, got[ms], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTunnel_Unimplemented(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, _ := storage.Open(filepath.Join(dir, "test.db"))
|
||||||
|
defer st.Close()
|
||||||
|
client, cleanup := newTestServer(t, st)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
stream, err := client.Tunnel(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stream.Send(&statsv1.Frame{Kind: statsv1.Frame_HELLO, Target: "1.2.3.4:80"})
|
||||||
|
_, err = stream.Recv()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected Unimplemented error")
|
||||||
|
}
|
||||||
|
// Just check it's an error; the exact code/message will be checked indirectly when Task 11 wires real impl.
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS samples (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
node TEXT NOT NULL,
|
||||||
|
ts TEXT NOT NULL,
|
||||||
|
http_latency_ms REAL NOT NULL,
|
||||||
|
tcp_rtt_ms REAL NOT NULL,
|
||||||
|
dns_resolve_ms REAL NOT NULL,
|
||||||
|
tls_handshake_ms REAL NOT NULL,
|
||||||
|
throughput_kbps INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_samples_node_ts ON samples (node, ts DESC);
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/bergabruh/stats-gateway/internal/probes"
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/*.sql
|
||||||
|
var migrations embed.FS
|
||||||
|
|
||||||
|
// tsLayout is RFC3339 with fixed 9-digit nanosecond fraction. Lexical
|
||||||
|
// ordering of formatted strings preserves chronological order — unlike
|
||||||
|
// time.RFC3339Nano which strips trailing zeros (".1Z" lex-sorts after
|
||||||
|
// ".100000001Z" because 'Z' > '0'). SQLite TEXT columns are compared
|
||||||
|
// lexically, so this matters for QueryRange boundaries.
|
||||||
|
const tsLayout = "2006-01-02T15:04:05.000000000Z07:00"
|
||||||
|
|
||||||
|
// Store wraps a SQLite database for probe sample persistence.
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open opens (or creates) the SQLite database at path and runs migrations.
|
||||||
|
func Open(path string) (*Store, error) {
|
||||||
|
db, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_busy_timeout=5000")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s := &Store{db: db}
|
||||||
|
if err := s.migrate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("migrate: %w", err)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the underlying database.
|
||||||
|
func (s *Store) Close() error { return s.db.Close() }
|
||||||
|
|
||||||
|
func (s *Store) migrate() error {
|
||||||
|
body, err := migrations.ReadFile("migrations/001_init.sql")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = s.db.Exec(string(body))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertSample stores a probe result for the given node.
|
||||||
|
func (s *Store) InsertSample(ctx context.Context, node string, r *probes.Result) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO samples (node, ts, http_latency_ms, tcp_rtt_ms, dns_resolve_ms, tls_handshake_ms, throughput_kbps)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, node, r.Timestamp.Format(tsLayout),
|
||||||
|
r.HTTPLatencyMs, r.TCPRttMs, r.DNSResolveMs, r.TLSHandshakeMs, r.ThroughputKbps)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryRecent returns up to limit samples for node ordered by ts DESC.
|
||||||
|
// If since is zero, no lower bound is applied. If node is empty, all nodes are returned.
|
||||||
|
func (s *Store) QueryRecent(ctx context.Context, node string, limit int, since time.Time) ([]probes.Result, error) {
|
||||||
|
q := `SELECT node, ts, http_latency_ms, tcp_rtt_ms, dns_resolve_ms, tls_handshake_ms, throughput_kbps
|
||||||
|
FROM samples WHERE 1=1`
|
||||||
|
args := []any{}
|
||||||
|
if node != "" {
|
||||||
|
q += ` AND node = ?`
|
||||||
|
args = append(args, node)
|
||||||
|
}
|
||||||
|
if !since.IsZero() {
|
||||||
|
q += ` AND ts >= ?`
|
||||||
|
args = append(args, since.Format(tsLayout))
|
||||||
|
}
|
||||||
|
q += ` ORDER BY ts DESC LIMIT ?`
|
||||||
|
args = append(args, limit)
|
||||||
|
|
||||||
|
return s.scanSamples(ctx, q, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryRange returns samples for [since, until). Used by Aggregate (Task 5).
|
||||||
|
func (s *Store) QueryRange(ctx context.Context, node string, since, until time.Time) ([]probes.Result, error) {
|
||||||
|
q := `SELECT node, ts, http_latency_ms, tcp_rtt_ms, dns_resolve_ms, tls_handshake_ms, throughput_kbps
|
||||||
|
FROM samples WHERE ts >= ? AND ts < ?`
|
||||||
|
args := []any{since.Format(tsLayout), until.Format(tsLayout)}
|
||||||
|
if node != "" {
|
||||||
|
q += ` AND node = ?`
|
||||||
|
args = append(args, node)
|
||||||
|
}
|
||||||
|
q += ` ORDER BY ts ASC`
|
||||||
|
return s.scanSamples(ctx, q, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) scanSamples(ctx context.Context, q string, args ...any) ([]probes.Result, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []probes.Result
|
||||||
|
for rows.Next() {
|
||||||
|
var ts string
|
||||||
|
var r probes.Result
|
||||||
|
if err := rows.Scan(&r.Node, &ts, &r.HTTPLatencyMs, &r.TCPRttMs, &r.DNSResolveMs, &r.TLSHandshakeMs, &r.ThroughputKbps); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.Timestamp, _ = time.Parse(tsLayout, ts)
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/bergabruh/stats-gateway/internal/probes"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInsertAndQuery(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, err := Open(filepath.Join(dir, "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
r := &probes.Result{
|
||||||
|
HTTPLatencyMs: 42.5,
|
||||||
|
TCPRttMs: 12.0,
|
||||||
|
DNSResolveMs: 3.0,
|
||||||
|
TLSHandshakeMs: 25.0,
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
if err := st.InsertSample(ctx, "fl", r); err != nil {
|
||||||
|
t.Fatalf("insert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
samples, err := st.QueryRecent(ctx, "fl", 10, time.Time{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query: %v", err)
|
||||||
|
}
|
||||||
|
if len(samples) != 1 {
|
||||||
|
t.Fatalf("expected 1 sample, got %d", len(samples))
|
||||||
|
}
|
||||||
|
if samples[0].HTTPLatencyMs != 42.5 {
|
||||||
|
t.Errorf("expected latency 42.5, got %f", samples[0].HTTPLatencyMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryRange(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, err := Open(filepath.Join(dir, "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now().UTC().Truncate(time.Second)
|
||||||
|
for i, dt := range []time.Duration{-2 * time.Hour, -1 * time.Hour, 0, 1 * time.Hour} {
|
||||||
|
r := &probes.Result{HTTPLatencyMs: float64(10 + i), Timestamp: now.Add(dt)}
|
||||||
|
if err := st.InsertSample(ctx, "fl", r); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := st.QueryRange(ctx, "fl", now.Add(-90*time.Minute), now.Add(30*time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// expect 2 samples: -1h and 0
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("expected 2, got %d", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQueryRange_SubsecondOrdering verifies fixed-width nanosecond format —
|
||||||
|
// regression guard for RFC3339Nano trailing-zero stripping bug.
|
||||||
|
func TestQueryRange_SubsecondOrdering(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, err := Open(filepath.Join(dir, "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
base := time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC)
|
||||||
|
// Two timestamps where lexical ordering of RFC3339Nano disagrees with chronology:
|
||||||
|
// t1 = base + 100ms → RFC3339Nano formats as "...:00.1Z"
|
||||||
|
// t2 = base + 100ms + 1ns → RFC3339Nano formats as "...:00.100000001Z"
|
||||||
|
// Lexically ".1Z" > ".100000001Z" because 'Z' > '0', but t1 < t2.
|
||||||
|
t1 := base.Add(100 * time.Millisecond)
|
||||||
|
t2 := base.Add(100*time.Millisecond + time.Nanosecond)
|
||||||
|
for _, ts := range []time.Time{t1, t2} {
|
||||||
|
if err := st.InsertSample(ctx, "fl", &probes.Result{HTTPLatencyMs: 1.0, Timestamp: ts}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Range starting just before t1 must include both samples.
|
||||||
|
got, err := st.QueryRange(ctx, "fl", base.Add(50*time.Millisecond), base.Add(200*time.Millisecond))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("expected 2 samples in [t-50ms, t+200ms), got %d — lexical ordering bug?", len(got))
|
||||||
|
}
|
||||||
|
// Verify chronological ordering preserved on read.
|
||||||
|
if !got[0].Timestamp.Before(got[1].Timestamp) {
|
||||||
|
t.Errorf("expected got[0] < got[1] chronologically; got[0]=%v got[1]=%v", got[0].Timestamp, got[1].Timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
PROTOC := protoc
|
||||||
|
GEN_OUT := ../gen/stats/v1
|
||||||
|
|
||||||
|
.PHONY: gen
|
||||||
|
gen:
|
||||||
|
mkdir -p $(GEN_OUT)
|
||||||
|
$(PROTOC) \
|
||||||
|
--go_out=$(GEN_OUT) --go_opt=paths=source_relative \
|
||||||
|
--go-grpc_out=$(GEN_OUT) --go-grpc_opt=paths=source_relative \
|
||||||
|
-I . stats.proto
|
||||||
|
|
||||||
|
.PHONY: clean
|
||||||
|
clean:
|
||||||
|
rm -rf $(GEN_OUT)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package stats.v1;
|
||||||
|
option go_package = "github.com/bergabruh/stats-gateway/gen/stats/v1;statsv1";
|
||||||
|
|
||||||
|
service SpeedStatus {
|
||||||
|
// RecentResults streams the last N samples for given filter.
|
||||||
|
rpc RecentResults(Filter) returns (stream Sample);
|
||||||
|
|
||||||
|
// Aggregate returns histogram for a time range.
|
||||||
|
rpc Aggregate(Range) returns (Histogram);
|
||||||
|
|
||||||
|
// Tunnel is a bidirectional stream for authenticated clients.
|
||||||
|
// Auth: Bearer JWT (HS256) в gRPC metadata "authorization: Bearer <jwt>".
|
||||||
|
// Server validates: signature, exp, scope=="tunnel", optional revocation list.
|
||||||
|
rpc Tunnel(stream Frame) returns (stream Frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
message Filter {
|
||||||
|
string node = 1; // "fl" | "pl1" | "" (all)
|
||||||
|
int32 limit = 2; // 1..1000
|
||||||
|
string since = 3; // RFC3339, optional
|
||||||
|
}
|
||||||
|
|
||||||
|
message Sample {
|
||||||
|
string node = 1;
|
||||||
|
string timestamp = 2; // RFC3339
|
||||||
|
double http_latency_ms = 3;
|
||||||
|
double tcp_rtt_ms = 4;
|
||||||
|
double dns_resolve_ms = 5;
|
||||||
|
double tls_handshake_ms = 6;
|
||||||
|
int64 throughput_kbps = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Range {
|
||||||
|
string node = 1;
|
||||||
|
string since = 2;
|
||||||
|
string until = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Histogram {
|
||||||
|
repeated Bucket buckets = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Bucket {
|
||||||
|
double upper_ms = 1;
|
||||||
|
int64 count = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Frame {
|
||||||
|
// Control message. First frame from client must be HELLO with target.
|
||||||
|
enum Kind {
|
||||||
|
UNKNOWN = 0;
|
||||||
|
HELLO = 1;
|
||||||
|
DATA = 2;
|
||||||
|
CLOSE = 3;
|
||||||
|
}
|
||||||
|
Kind kind = 1;
|
||||||
|
string target = 2; // "ip:port" — only used in HELLO
|
||||||
|
bytes payload = 3; // raw TCP bytes
|
||||||
|
bytes padding = 4; // random padding для frame-length obfuscation
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
`)
|
||||||
|
}
|
||||||
Executable
+41
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Renders stats-site (Hugo project) without requiring Hugo, deploys atomically
|
||||||
|
# to /var/www/stats-site/. The Hugo project uses only two template variables;
|
||||||
|
# we substitute them with sed.
|
||||||
|
#
|
||||||
|
# Usage: build.sh [--src DIR] [--dest DIR]
|
||||||
|
# --src default /opt/stats-site
|
||||||
|
# --dest default /var/www/stats-site
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SRC="${SRC:-/opt/stats-site}"
|
||||||
|
DEST="${DEST:-/var/www/stats-site}"
|
||||||
|
|
||||||
|
# Hugo variables (mirror config.toml).
|
||||||
|
SITE_TITLE='PSP Speedtest Stats'
|
||||||
|
SITE_DESC='Метрики латентности и времени отклика двух geo-распределённых OpenSpeedTest нод. Snapshot обновляется каждые 10 минут.'
|
||||||
|
|
||||||
|
[ -d "$SRC" ] || { echo "src $SRC not found" >&2; exit 1; }
|
||||||
|
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
trap "rm -rf $TMP" EXIT
|
||||||
|
|
||||||
|
# 1. Copy static/ verbatim
|
||||||
|
cp -r "$SRC/static/." "$TMP/"
|
||||||
|
|
||||||
|
# 2. Render layouts/index.html with sed-substitution
|
||||||
|
sed \
|
||||||
|
-e "s|{{ \.Site\.Title }}|${SITE_TITLE}|g" \
|
||||||
|
-e "s|{{ \.Site\.Params\.description }}|${SITE_DESC}|g" \
|
||||||
|
"$SRC/layouts/index.html" > "$TMP/index.html"
|
||||||
|
|
||||||
|
# 3. Atomic rsync, preserving runtime data (snapshot.json) and ownership.
|
||||||
|
# --delete removes files in DEST that aren't in TMP (cleans old assets),
|
||||||
|
# --exclude protects data/ (snapshot generator owns that).
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude='data/snapshot.json' \
|
||||||
|
--exclude='data/snapshot.json.tmp' \
|
||||||
|
--chown=deploy:www-data \
|
||||||
|
"$TMP/" "$DEST/"
|
||||||
|
|
||||||
|
echo "site built: $(find $DEST -type f | wc -l) files in $DEST"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
baseURL = "https://stats.projectshitpost.fun/"
|
||||||
|
title = "PSP Speedtest Stats"
|
||||||
|
languageCode = "ru"
|
||||||
|
defaultContentLanguage = "ru"
|
||||||
|
|
||||||
|
[params]
|
||||||
|
description = "Метрики латентности и времени отклика двух geo-распределённых OpenSpeedTest нод. Snapshot обновляется каждые 10 минут."
|
||||||
|
|
||||||
|
[markup.goldmark.renderer]
|
||||||
|
unsafe = true
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
---
|
||||||
|
title: "PSP Speedtest Stats"
|
||||||
|
---
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{ .Site.Title }} — Доступность FL и PL1</title>
|
||||||
|
<meta name="description" content="{{ .Site.Params.description }}">
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>{{ .Site.Title }}</h1>
|
||||||
|
<p class="subtitle">Network probe stats — обновляется каждые 10 минут.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="nodes">
|
||||||
|
<article class="node" id="node-fl">
|
||||||
|
<h2>FL — Финляндия</h2>
|
||||||
|
<div class="metrics" data-node="fl">
|
||||||
|
<p class="loading">Загрузка…</p>
|
||||||
|
</div>
|
||||||
|
<canvas class="chart" data-node="fl" width="600" height="180"></canvas>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="node" id="node-pl1">
|
||||||
|
<h2>PL1 — Польша</h2>
|
||||||
|
<div class="metrics" data-node="pl1">
|
||||||
|
<p class="loading">Загрузка…</p>
|
||||||
|
</div>
|
||||||
|
<canvas class="chart" data-node="pl1" width="600" height="180"></canvas>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="meta">
|
||||||
|
<p id="generated-at">Snapshot age: <span data-bind="age">—</span></p>
|
||||||
|
<p>Probes: HTTP HEAD, TCP RTT, DNS resolve, TLS handshake.</p>
|
||||||
|
<p>Источник: <a href="https://gitea.projectshitpost.fun/bergabruh/stats-gateway">stats-gateway</a> (open-source).</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav class="footer-nav">
|
||||||
|
<a href="/about/">About</a>
|
||||||
|
</nav>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/js/charts.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>About — PSP Speedtest Stats</title>
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>About</h1>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<p>Этот dashboard показывает результаты регулярных HTTP-проб двух
|
||||||
|
speedtest-нод (FL — Финляндия, PL1 — Польша) от gitea-CI/CD-узла,
|
||||||
|
раз в 5 минут. Данные хранятся в SQLite на gitea-сервере и
|
||||||
|
публикуются как snapshot.json раз в 10 минут.</p>
|
||||||
|
|
||||||
|
<p>Цель — мониторить деградации latency/throughput для собственного
|
||||||
|
использования. API сервиса описан в
|
||||||
|
<a href="https://gitea.projectshitpost.fun/bergabruh/stats-gateway">stats-gateway</a>.</p>
|
||||||
|
|
||||||
|
<p><a href="/">← Назад</a></p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--fg: #1a1a1a;
|
||||||
|
--bg: #fafafa;
|
||||||
|
--muted: #555;
|
||||||
|
--border: #ddd;
|
||||||
|
--accent: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--fg: #e8e8e8;
|
||||||
|
--bg: #111;
|
||||||
|
--muted: #aaa;
|
||||||
|
--border: #333;
|
||||||
|
--accent: #60a5fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 2em auto;
|
||||||
|
padding: 0 1em;
|
||||||
|
color: var(--fg);
|
||||||
|
background: var(--bg);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 1.4em;
|
||||||
|
margin-bottom: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodes {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.5em;
|
||||||
|
margin: 2em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.nodes { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.node {
|
||||||
|
padding: 1em;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--bg) 92%, var(--fg) 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 1.15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics .loading { color: var(--muted); font-style: italic; }
|
||||||
|
|
||||||
|
.metrics .row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.25em 0;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics .row:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.metrics .label { color: var(--muted); }
|
||||||
|
|
||||||
|
.metrics .value { font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 180px;
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin: 2em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-nav a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
margin-right: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-nav a:hover { text-decoration: underline; }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Snapshot files deployed at runtime by stats-snapshot.timer
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const SNAPSHOT_URL = '/data/snapshot.json';
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
const r = await fetch(SNAPSHOT_URL, { cache: 'no-cache' });
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
data = await r.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('snapshot load failed', e);
|
||||||
|
document.querySelectorAll('.metrics').forEach(el => {
|
||||||
|
el.innerHTML = '<p class="loading">Snapshot не загружен (' + e.message + ').</p>';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAge(data.generated_at);
|
||||||
|
|
||||||
|
for (const node of ['fl', 'pl1']) {
|
||||||
|
const nd = data.nodes && data.nodes[node];
|
||||||
|
if (!nd || !nd.recent || nd.recent.length === 0) {
|
||||||
|
renderMetrics(node, null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const samples = nd.recent;
|
||||||
|
renderMetrics(node, samples);
|
||||||
|
renderChart(node, samples);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAge(generatedAt) {
|
||||||
|
const el = document.querySelector('[data-bind="age"]');
|
||||||
|
if (!el || !generatedAt) return;
|
||||||
|
const ts = new Date(generatedAt);
|
||||||
|
const ageS = Math.round((Date.now() - ts.getTime()) / 1000);
|
||||||
|
if (ageS < 90) el.textContent = ageS + 's';
|
||||||
|
else if (ageS < 5400) el.textContent = Math.round(ageS / 60) + 'min';
|
||||||
|
else el.textContent = Math.round(ageS / 3600) + 'h';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMetrics(node, samples) {
|
||||||
|
const block = document.querySelector('.metrics[data-node="' + node + '"]');
|
||||||
|
if (!block) return;
|
||||||
|
if (!samples || samples.length === 0) {
|
||||||
|
block.innerHTML = '<p class="loading">Нет данных за окно.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// most-recent sample
|
||||||
|
const latest = samples[0];
|
||||||
|
// p50/p95 of http_ms over the window
|
||||||
|
const httpVals = samples.map(s => s.http_ms).filter(v => typeof v === 'number').sort((a, b) => a - b);
|
||||||
|
const p50 = httpVals[Math.floor(httpVals.length * 0.5)] || 0;
|
||||||
|
const p95 = httpVals[Math.floor(httpVals.length * 0.95)] || 0;
|
||||||
|
|
||||||
|
block.innerHTML = [
|
||||||
|
row('Last HTTP', fmtMs(latest.http_ms)),
|
||||||
|
row('Last TLS handshake', fmtMs(latest.tls_ms)),
|
||||||
|
row('p50 HTTP (24h)', fmtMs(p50)),
|
||||||
|
row('p95 HTTP (24h)', fmtMs(p95)),
|
||||||
|
row('Samples (24h)', String(samples.length)),
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function row(label, value) {
|
||||||
|
return '<div class="row"><span class="label">' + label + '</span><span class="value">' + value + '</span></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtMs(v) {
|
||||||
|
if (typeof v !== 'number') return '—';
|
||||||
|
if (v < 10) return v.toFixed(2) + ' ms';
|
||||||
|
if (v < 100) return v.toFixed(1) + ' ms';
|
||||||
|
return Math.round(v) + ' ms';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChart(node, samples) {
|
||||||
|
const canvas = document.querySelector('.chart[data-node="' + node + '"]');
|
||||||
|
if (!canvas || !canvas.getContext) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
// High-DPI scaling
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const cssW = canvas.clientWidth || canvas.width;
|
||||||
|
const cssH = canvas.height;
|
||||||
|
canvas.width = cssW * dpr;
|
||||||
|
canvas.height = cssH * dpr;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, cssW, cssH);
|
||||||
|
|
||||||
|
const points = samples
|
||||||
|
.map(s => ({ t: new Date(s.ts).getTime(), v: s.http_ms }))
|
||||||
|
.filter(p => !isNaN(p.t) && typeof p.v === 'number')
|
||||||
|
.sort((a, b) => a.t - b.t);
|
||||||
|
if (points.length === 0) return;
|
||||||
|
|
||||||
|
const tMin = points[0].t;
|
||||||
|
const tMax = points[points.length - 1].t;
|
||||||
|
const tSpan = Math.max(1, tMax - tMin);
|
||||||
|
const vMax = Math.max(...points.map(p => p.v)) * 1.1 || 1;
|
||||||
|
|
||||||
|
// axes
|
||||||
|
const pad = 24;
|
||||||
|
const w = cssW - pad * 2;
|
||||||
|
const h = cssH - pad * 2;
|
||||||
|
const muted = getComputedStyle(document.documentElement).getPropertyValue('--muted').trim() || '#555';
|
||||||
|
const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#2563eb';
|
||||||
|
|
||||||
|
ctx.strokeStyle = muted;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pad, pad);
|
||||||
|
ctx.lineTo(pad, pad + h);
|
||||||
|
ctx.lineTo(pad + w, pad + h);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// y-axis label (max value)
|
||||||
|
ctx.fillStyle = muted;
|
||||||
|
ctx.font = '10px system-ui, sans-serif';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.fillText(Math.round(vMax) + ' ms', pad - 2, pad + 8);
|
||||||
|
ctx.fillText('0', pad - 2, pad + h);
|
||||||
|
|
||||||
|
// points
|
||||||
|
ctx.fillStyle = accent;
|
||||||
|
for (const p of points) {
|
||||||
|
const x = pad + ((p.t - tMin) / tSpan) * w;
|
||||||
|
const y = pad + h - (p.v / vMax) * h;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, 1.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', load);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=stats-gateway probe collector
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=stats
|
||||||
|
Group=stats
|
||||||
|
ExecStart=/usr/local/bin/stats-collector --db /var/lib/stats-gateway/stats.db
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=stats-gateway probe every 5 minutes
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=2min
|
||||||
|
OnUnitActiveSec=5min
|
||||||
|
RandomizedDelaySec=30s
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=stats-gateway gRPC service
|
||||||
|
After=network.target
|
||||||
|
Requires=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=stats
|
||||||
|
Group=stats
|
||||||
|
ExecStart=/usr/local/bin/stats-gateway --addr 127.0.0.1:50051 --db /var/lib/stats-gateway/stats.db
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
# hardening
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ReadWritePaths=/var/lib/stats-gateway
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Render stats-site templates and deploy to /var/www/stats-site
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=root
|
||||||
|
Group=root
|
||||||
|
ExecStart=/opt/stats-site/build.sh
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ReadWritePaths=/var/www/stats-site
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Run stats-site rebuild hourly
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=5min
|
||||||
|
OnUnitActiveSec=1h
|
||||||
|
RandomizedDelaySec=2min
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Generate stats snapshot.json for stats-site dashboard
|
||||||
|
After=stats-gateway.service
|
||||||
|
Wants=stats-gateway.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=stats
|
||||||
|
Group=www-data
|
||||||
|
ExecStart=/usr/local/bin/stats-snapshot --db /var/lib/stats-gateway/stats.db --out /var/www/stats-site/data/snapshot.json
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
# Need write access to /var/www/stats-site/data
|
||||||
|
ReadWritePaths=/var/www/stats-site/data
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Run stats snapshot every 10 minutes
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=3min
|
||||||
|
OnUnitActiveSec=10min
|
||||||
|
RandomizedDelaySec=30s
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
Reference in New Issue
Block a user