Initial commit: stats-gateway gRPC service + dashboard + CLI
This commit is contained in:
@@ -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.
|
||||
}
|
||||
Reference in New Issue
Block a user