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