mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-16 05:10:01 +00:00
GET /traces takes an optional cursorTag so tags sharing a millisecond survive paging. Migrations run under an advisory lock and record each file in the same implicit transaction, so a crash can't strand a fresh database. Overview windowHours counts the hours it covers, hourly observer activity fills unrolled hours from raw rows, and integration fixtures are UTC.
202 lines
5.5 KiB
Go
202 lines
5.5 KiB
Go
// Copyright 2026 Beacon Contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package db
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc"
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/ingest"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
type tracePayload struct {
|
|
PathHashes []string `json:"pathHashes"`
|
|
Flags byte `json:"flags"`
|
|
SNRValues []float32 `json:"snrValues"`
|
|
}
|
|
|
|
func (s *Store) RecordTrace(ctx context.Context, h ingest.TraceHearing) error {
|
|
return s.q.RecordTrace(ctx, sqlc.RecordTraceParams{
|
|
TraceTag: h.TraceTag,
|
|
Iata: h.IATA,
|
|
HeardAt: pgtype.Timestamptz{Time: h.HeardAt, Valid: true},
|
|
})
|
|
}
|
|
|
|
func (s *Store) DeleteOldTraceTags(ctx context.Context, cutoff time.Time) error {
|
|
return s.q.DeleteOldTraceTags(ctx, pgtype.Timestamptz{Time: cutoff, Valid: true})
|
|
}
|
|
|
|
func (s *Store) DeleteOldTraceIATAs(ctx context.Context, cutoff time.Time) error {
|
|
return s.q.DeleteOldTraceIATAs(ctx, pgtype.Timestamptz{Time: cutoff, Valid: true})
|
|
}
|
|
|
|
func (s *Store) ListTraceTags(ctx context.Context, iatas []string, scope, traceType string, since, until time.Time, cursor time.Time, cursorTag string, limit int32) ([]api.TraceTagSummary, error) {
|
|
var sinceTS, untilTS, cursorTS pgtype.Timestamptz
|
|
if !since.IsZero() {
|
|
sinceTS = pgtype.Timestamptz{Time: since, Valid: true}
|
|
}
|
|
if !until.IsZero() {
|
|
untilTS = pgtype.Timestamptz{Time: until, Valid: true}
|
|
}
|
|
if !cursor.IsZero() {
|
|
cursorTS = pgtype.Timestamptz{Time: cursor, Valid: true}
|
|
}
|
|
var tag []byte
|
|
if cursorTag != "" {
|
|
b, err := hex.DecodeString(cursorTag)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cursor tag: %w", err)
|
|
}
|
|
tag = b
|
|
}
|
|
rows, err := s.q.ListTraceTags(ctx, sqlc.ListTraceTagsParams{
|
|
Column1: iatas,
|
|
Column2: scope,
|
|
Column3: sinceTS,
|
|
Column4: untilTS,
|
|
Column5: cursorTS,
|
|
Limit: limit,
|
|
Column7: traceType,
|
|
Column8: tag,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]api.TraceTagSummary, 0, len(rows))
|
|
for _, r := range rows {
|
|
var best tracePayload
|
|
if len(r.BestPayload) > 0 {
|
|
_ = json.Unmarshal(r.BestPayload, &best)
|
|
}
|
|
items = append(items, api.TraceTagSummary{
|
|
TraceTag: r.TraceTag,
|
|
FirstHeardAt: r.FirstHeardAt.Time.UnixMilli(),
|
|
LastHeardAt: r.LastHeardAt.Time.UnixMilli(),
|
|
PacketCount: r.PacketCount,
|
|
IATACount: r.IataCount,
|
|
TraceType: r.TraceType,
|
|
PathHashes: best.PathHashes,
|
|
SNRValues: best.SNRValues,
|
|
})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (s *Store) GetTraceByTag(ctx context.Context, tag string) (*api.TraceDetail, error) {
|
|
rows, err := s.q.GetPacketsByTraceTag(ctx, tag)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil, nil
|
|
}
|
|
detail := &api.TraceDetail{
|
|
TraceTag: tag,
|
|
Packets: make([]api.TracePacket, 0, len(rows)),
|
|
}
|
|
for _, r := range rows {
|
|
packet := api.TracePacket{
|
|
PacketHash: r.PacketHashHex,
|
|
RouteType: r.RouteType,
|
|
RouteTypeName: api.RouteTypeName(r.RouteType),
|
|
Scope: r.ScopeName,
|
|
FirstHeardAt: r.FirstHeardAt.Time.UnixMilli(),
|
|
LastHeardAt: r.LastHeardAt.Time.UnixMilli(),
|
|
}
|
|
var parsed tracePayload
|
|
if err := json.Unmarshal(r.ParsedPayload, &parsed); err == nil {
|
|
// build raw path
|
|
rawPath := make([]api.RawHop, 0, len(parsed.PathHashes))
|
|
for i, h := range parsed.PathHashes {
|
|
hop := api.RawHop{Hash: h}
|
|
if i < len(parsed.SNRValues) {
|
|
snr := parsed.SNRValues[i]
|
|
hop.SNR = &snr
|
|
}
|
|
rawPath = append(rawPath, hop)
|
|
}
|
|
packet.RawPath = rawPath
|
|
}
|
|
if len(r.Iatas) > 0 {
|
|
packet.ResolvedRoute = s.resolveTraceRoute(ctx, &parsed, r.Iatas)
|
|
}
|
|
detail.Packets = append(detail.Packets, packet)
|
|
}
|
|
return detail, nil
|
|
}
|
|
|
|
func (s *Store) resolveTraceRoute(ctx context.Context, payload *tracePayload, iatas []string) []api.ResolvedHop {
|
|
if payload == nil || len(payload.PathHashes) == 0 {
|
|
return nil
|
|
}
|
|
hashSize := int(1 << (payload.Flags & 0x03))
|
|
hashes := make([][]byte, 0, len(payload.PathHashes))
|
|
for _, h := range payload.PathHashes {
|
|
b, err := hex.DecodeString(h)
|
|
if err == nil {
|
|
hashes = append(hashes, b)
|
|
}
|
|
}
|
|
confidenceRank := map[string]int{"none": 0, "ambiguous": 1, "high": 2}
|
|
type hopResult struct {
|
|
confidence string
|
|
entries []api.ResolvedPathEntry
|
|
}
|
|
merged := make([]hopResult, len(hashes))
|
|
for i := range merged {
|
|
merged[i] = hopResult{confidence: "none"}
|
|
}
|
|
for _, iata := range iatas {
|
|
resolved, err := s.ResolvePathHashes(ctx, iata, hashes)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for i, hash := range hashes {
|
|
key := hex.EncodeToString(hash[:hashSize])
|
|
entries := resolved[key]
|
|
var confidence string
|
|
switch len(entries) {
|
|
case 0:
|
|
confidence = "none"
|
|
case 1:
|
|
confidence = "high"
|
|
default:
|
|
confidence = "ambiguous"
|
|
}
|
|
if confidenceRank[confidence] > confidenceRank[merged[i].confidence] {
|
|
merged[i] = hopResult{confidence: confidence, entries: entries}
|
|
}
|
|
}
|
|
}
|
|
route := make([]api.ResolvedHop, 0, len(hashes))
|
|
for i, hr := range merged {
|
|
hop := api.ResolvedHop{
|
|
Confidence: hr.confidence,
|
|
Nodes: make([]api.ResolvedNode, 0, len(hr.entries)),
|
|
}
|
|
if i < len(payload.SNRValues) {
|
|
snr := payload.SNRValues[i]
|
|
hop.SNR = &snr
|
|
}
|
|
for _, e := range hr.entries {
|
|
hop.Nodes = append(hop.Nodes, api.ResolvedNode{
|
|
ID: e.NodeID,
|
|
Name: e.Name,
|
|
Latitude: e.Latitude,
|
|
Longitude: e.Longitude,
|
|
PublicKey: hex.EncodeToString(e.PublicKey),
|
|
})
|
|
}
|
|
route = append(route, hop)
|
|
}
|
|
return route
|
|
}
|