// 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 }