mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-09-02 00:58:22 +00:00
1174 lines
38 KiB
Go
1174 lines
38 KiB
Go
// Package db implements the ingest.DB interface using sqlc-generated queries
|
|
// over a pgx/v5 connection pool. Each method is a thin mapping layer between
|
|
// the ingest param structs and the sqlc-generated param structs.
|
|
package db
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"errors"
|
|
"log"
|
|
"time"
|
|
|
|
sqlc "github.com/MeshCore-Tower/tower-server/db/sqlc"
|
|
"github.com/MeshCore-Tower/tower-server/internal/api"
|
|
"github.com/MeshCore-Tower/tower-server/internal/ingest"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
type Store struct {
|
|
q *sqlc.Queries
|
|
}
|
|
|
|
func New(pool *pgxpool.Pool) *Store {
|
|
return &Store{q: sqlc.New(pool)}
|
|
}
|
|
|
|
// UpsertObserver upserts the observers row keyed on pubkey.
|
|
func (s *Store) UpsertObserver(ctx context.Context, pubkey []byte) (uuid.UUID, string, error) {
|
|
row, err := s.q.UpsertObserver(ctx, pubkey)
|
|
if err != nil {
|
|
return uuid.Nil, "", err
|
|
}
|
|
displayName := ""
|
|
if row.DisplayName != nil {
|
|
displayName = *row.DisplayName
|
|
}
|
|
return row.ID, displayName, err
|
|
}
|
|
|
|
// UpsertObserverBroker records that this observer was seen on brokerName.
|
|
func (s *Store) UpsertObserverBroker(ctx context.Context, observerID uuid.UUID, brokerName string) error {
|
|
params := sqlc.UpsertObserverBrokerParams{
|
|
ObserverID: observerID,
|
|
BrokerName: brokerName,
|
|
}
|
|
return s.q.UpsertObserverBroker(ctx, params)
|
|
}
|
|
|
|
// UpsertIATA auto-creates an iata_codes row if it doesn't exist yet.
|
|
func (s *Store) UpsertIATA(ctx context.Context, iata string) error {
|
|
return s.q.UpsertIATA(ctx, iata)
|
|
}
|
|
|
|
// UpsertPacket inserts or bumps the packets row. Returns (isNew, error).
|
|
func (s *Store) UpsertPacket(ctx context.Context, p ingest.UpsertPacketParams) (bool, error) {
|
|
var regionCode, subRegionCode *int32
|
|
hasTransportCodes := len(p.TransportCodes) == 4
|
|
if hasTransportCodes {
|
|
r := int32(binary.LittleEndian.Uint16(p.TransportCodes[0:2]))
|
|
s := int32(binary.LittleEndian.Uint16(p.TransportCodes[2:4]))
|
|
regionCode = &r
|
|
subRegionCode = &s
|
|
}
|
|
params := sqlc.UpsertPacketParams{
|
|
PacketHash: p.PacketHash,
|
|
PayloadType: int16(p.PayloadType),
|
|
PayloadVersion: int16(p.PayloadVersion),
|
|
RouteType: int16(p.RouteType),
|
|
TransportCodesPresent: &hasTransportCodes,
|
|
RegionCode: regionCode,
|
|
SubRegionCode: subRegionCode,
|
|
OriginPubkey: p.OriginPubkey,
|
|
RawPayload: p.RawPayload,
|
|
ParsedPayload: p.ParsedPayload,
|
|
ChannelHash: p.ChannelHash,
|
|
}
|
|
row, err := s.q.UpsertPacket(ctx, params)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return row.Inserted, nil
|
|
}
|
|
|
|
// InsertObservation inserts a packet_observations row.
|
|
// Returns (inserted, error); inserted=false means ON CONFLICT DO NOTHING fired.
|
|
func (s *Store) InsertObservation(ctx context.Context, o ingest.InsertObservationParams) (bool, error) {
|
|
params := sqlc.InsertObservationParams{
|
|
PacketHash: o.PacketHash,
|
|
ObserverID: o.ObserverID,
|
|
Iata: o.IATA,
|
|
HeardAt: pgtype.Timestamptz{Time: o.HeardAt, Valid: true},
|
|
PathLengthByte: int16(o.PathLengthByte),
|
|
HashSize: int16(o.HashSize),
|
|
HopCount: int16(o.HopCount),
|
|
PathBytes: o.PathBytes,
|
|
Rssi: &o.RSSI,
|
|
Snr: &o.SNR,
|
|
PropagationTimeMs: &o.PropagationTimeMs,
|
|
RadioFreqMhz: &o.RadioFreqMHz,
|
|
SpreadFactor: &o.SpreadFactor,
|
|
BandwidthKhz: &o.BandwidthKHz,
|
|
CodingRate: &o.CodingRate,
|
|
SourceBroker: &o.SourceBroker,
|
|
}
|
|
row, err := s.q.InsertObservation(ctx, params)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return false, nil // conflict, not an error
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return row.ID != 0, nil
|
|
}
|
|
|
|
// SetNodeCapability flips supports_multibyte_paths or supports_multibyte_traces
|
|
// for a node, never downgrading an existing TRUE.
|
|
func (s *Store) SetNodeCapability(ctx context.Context, nodeID uuid.UUID, paths, traces bool) error {
|
|
var errs []error
|
|
if paths {
|
|
errs = append(errs, s.q.SetNodeMultibytePaths(ctx, nodeID))
|
|
}
|
|
if traces {
|
|
errs = append(errs, s.q.SetNodeMultibyteTraces(ctx, nodeID))
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
// UpsertNode upserts a nodes row from an advert payload.
|
|
func (s *Store) UpsertNode(ctx context.Context, n ingest.UpsertNodeParams) (uuid.UUID, error) {
|
|
params := sqlc.UpsertNodeParams{
|
|
PublicKey: n.PublicKey,
|
|
NodeType: int16(n.NodeType),
|
|
Name: &n.Name,
|
|
Latitude: n.Latitude,
|
|
Longitude: n.Longitude,
|
|
}
|
|
row, err := s.q.UpsertNode(ctx, params)
|
|
if err != nil {
|
|
return uuid.Nil, err
|
|
}
|
|
return row.ID, nil
|
|
}
|
|
|
|
// UpsertNodeIATA upserts a node_iatas row.
|
|
func (s *Store) UpsertNodeIATA(ctx context.Context, nodeID uuid.UUID, iata string) error {
|
|
params := sqlc.UpsertNodeIATAParams{NodeID: nodeID, Iata: iata}
|
|
return s.q.UpsertNodeIATA(ctx, params)
|
|
}
|
|
|
|
// InsertChannelMessage stores a decrypted group text message.
|
|
func (s *Store) InsertChannelMessage(ctx context.Context, m ingest.InsertChannelMessageParams) (bool, error) {
|
|
params := sqlc.InsertChannelMessageParams{ChannelID: int32(m.ChannelID), PacketHash: m.PacketHash, SenderName: &m.SenderName, Content: &m.Content, SentAt: pgtype.Timestamptz{Time: m.SentAt, Valid: true}}
|
|
_, err := s.q.InsertChannelMessage(ctx, params)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return false, nil // duplicate
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// UpdateObserverStatus updates the observer row from a /status message.
|
|
// Column2 = display_name, Column3 = observer_type (sqlc loses names inside CASE expressions).
|
|
func (s *Store) UpdateObserverStatus(ctx context.Context, p ingest.UpdateObserverStatusParams) (uuid.UUID, error) {
|
|
params := sqlc.UpdateObserverStatusParams{PublicKey: p.PublicKey, Column2: p.DisplayName, Column3: p.ObserverType, SoftwareVersion: &p.SoftwareVersion, HardwareModel: &p.HardwareModel, FirmwareVersion: &p.FirmwareVersion, FirmwareBuild: &p.FirmwareBuild, RadioFreqMhz: &p.RadioFreqMHz, RadioSf: &p.RadioSF, RadioBwKhz: &p.RadioBWKHz, RadioCr: &p.RadioCR, BatteryLevel: p.BatteryLevel, UptimeSeconds: p.UptimeSeconds, StatusMetadata: p.StatusMetadata}
|
|
return s.q.UpdateObserverStatus(ctx, params)
|
|
}
|
|
|
|
// GetObserverLastIATA returns the IATA from the most recent observation for the given observer.
|
|
func (s *Store) GetObserverLastIATA(ctx context.Context, observerID uuid.UUID) (string, error) {
|
|
return s.q.GetObserverLastIATA(ctx, observerID)
|
|
}
|
|
|
|
// GetObserverRadio returns the current radio settings for the given observer.
|
|
func (s *Store) GetObserverRadio(ctx context.Context, observerID uuid.UUID) (ingest.RadioSettings, error) {
|
|
row, err := s.q.GetObserverRadio(ctx, observerID)
|
|
if err != nil {
|
|
return ingest.RadioSettings{}, err
|
|
}
|
|
var settings ingest.RadioSettings
|
|
if row.RadioFreqMhz != nil {
|
|
settings.FreqMHz = *row.RadioFreqMhz
|
|
}
|
|
if row.RadioSf != nil {
|
|
settings.SF = *row.RadioSf
|
|
}
|
|
if row.RadioBwKhz != nil {
|
|
settings.BWKHz = *row.RadioBwKhz
|
|
}
|
|
if row.RadioCr != nil {
|
|
settings.CR = *row.RadioCr
|
|
}
|
|
return settings, nil
|
|
}
|
|
|
|
// ResolvePathHashes returns a list of node UUIDs for the given path hash prefixes and IATA.
|
|
// Hash size is inferred from the length of the first element in hashes.
|
|
func (s *Store) ResolvePathHashes(ctx context.Context, iata string, hashes [][]byte) ([]uuid.UUID, error) {
|
|
rows, err := s.q.ResolvePathHashes(ctx, sqlc.ResolvePathHashesParams{
|
|
Iata: iata,
|
|
Column2: hashes,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ids := make([]uuid.UUID, len(rows))
|
|
copy(ids, rows)
|
|
return ids, nil
|
|
}
|
|
|
|
// UpsertChannel upserts a channel row by (hash, keyFingerprint) and returns its integer ID.
|
|
// Pass nil keyFingerprint to record a hash-only row when the key is unknown.
|
|
// name and hashtag are optional metadata stored on the channel row.
|
|
func (s *Store) UpsertChannel(ctx context.Context, channelHash []byte, keyFingerprint []byte, name string, hashtag string) (int, error) {
|
|
var namePtr, hashtagPtr *string
|
|
if name != "" {
|
|
namePtr = &name
|
|
}
|
|
if hashtag != "" {
|
|
hashtagPtr = &hashtag
|
|
}
|
|
isHashtag := hashtag != ""
|
|
row, err := s.q.UpsertChannel(ctx, sqlc.UpsertChannelParams{
|
|
ChannelHash: channelHash,
|
|
Column2: keyFingerprint, // key_fingerprint
|
|
Name: namePtr,
|
|
Hashtag: hashtagPtr,
|
|
IsHashtag: &isHashtag,
|
|
MessageCount: nil, // message count bumped separately by InsertChannelMessage
|
|
})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return int(row.ID), nil
|
|
}
|
|
|
|
// UpsertChannelHashOnly upserts a hash-only channel row for cases where the
|
|
// channel key is unknown. Uses the partial unique index to ensure only one
|
|
// hash-only row exists per channel hash. The return value is the channel ID
|
|
// but can be safely ignored since unknown-key channels have no messages.
|
|
func (s *Store) UpsertChannelHashOnly(ctx context.Context, channelHash []byte) (int, error) {
|
|
rowID, err := s.q.UpsertChannelHashOnly(ctx, channelHash)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return int(rowID), nil
|
|
}
|
|
|
|
// ListIATAs returns all known IATA codes with display name and coordinates.
|
|
// IATAs are auto-created on first packet arrival from that location.
|
|
func (s *Store) ListIATAs(ctx context.Context) ([]api.IATA, error) {
|
|
rows, err := s.q.ListIATAs(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
iatas := make([]api.IATA, 0, len(rows))
|
|
for _, v := range rows {
|
|
iatas = append(iatas, api.IATA{
|
|
IATA: v.Iata,
|
|
DisplayName: v.DisplayName,
|
|
Lat: v.ApproxLat,
|
|
Lng: v.ApproxLng,
|
|
})
|
|
}
|
|
return iatas, nil
|
|
}
|
|
|
|
// GetIATA returns a single IATA code by its 3-letter identifier.
|
|
// Returns nil, error if the IATA code is not found.
|
|
func (s *Store) GetIATA(ctx context.Context, iata string) (*api.IATA, error) {
|
|
i, err := s.q.GetIATA(ctx, iata)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &api.IATA{
|
|
IATA: i.Iata,
|
|
DisplayName: i.DisplayName,
|
|
Lat: i.ApproxLat,
|
|
Lng: i.ApproxLng,
|
|
}, nil
|
|
}
|
|
|
|
// ListRegions returns a summary list of all regions ordered by display_order then name.
|
|
// Use GetRegion for full detail including associated IATAs.
|
|
func (s *Store) ListRegions(ctx context.Context) ([]api.RegionSummary, error) {
|
|
rows, err := s.q.ListRegions(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
regions := make([]api.RegionSummary, 0, len(rows))
|
|
for _, v := range rows {
|
|
regions = append(regions, api.RegionSummary{
|
|
ID: int(v.ID),
|
|
Slug: v.Slug,
|
|
Name: v.Name,
|
|
})
|
|
}
|
|
return regions, nil
|
|
}
|
|
|
|
// GetRegion returns full detail for a single region including its associated IATA codes.
|
|
// Returns nil, pgx.ErrNoRows if the region is not found.
|
|
func (s *Store) GetRegion(ctx context.Context, regionID int32) (*api.Region, error) {
|
|
region, err := s.q.GetRegion(ctx, regionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := api.Region{
|
|
RegionSummary: api.RegionSummary{
|
|
ID: int(region.ID),
|
|
Slug: region.Slug,
|
|
Name: region.Name,
|
|
},
|
|
Description: region.Description,
|
|
CenterLat: region.CenterLat,
|
|
CenterLng: region.CenterLng,
|
|
}
|
|
var zoomLevel *int
|
|
if region.ZoomLevel != nil {
|
|
z := int(*region.ZoomLevel)
|
|
zoomLevel = &z
|
|
}
|
|
result.ZoomLevel = zoomLevel
|
|
iatas, err := s.q.GetRegionIATAs(ctx, regionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result.IATAs = iatas
|
|
return &result, nil
|
|
}
|
|
|
|
// UpsertIATADetails updates an existing iata_codes row with display name and coordinates.
|
|
// The row must already exist (auto-created on first packet arrival).
|
|
// Safe to call on startup — does nothing if the IATA has not been seen yet.
|
|
func (s *Store) UpsertIATADetails(ctx context.Context, iata string, name string, lat, lng *float64) error {
|
|
return s.q.UpsertIATADetails(ctx, sqlc.UpsertIATADetailsParams{
|
|
Iata: iata,
|
|
DisplayName: &name,
|
|
ApproxLat: lat,
|
|
ApproxLng: lng,
|
|
})
|
|
}
|
|
|
|
// UpsertRegion inserts or updates a region row by slug. Returns the region ID.
|
|
func (s *Store) UpsertRegion(ctx context.Context, slug, name, description string, displayOrder int, centerLat, centerLng *float64, zoomLevel *int) (int32, error) {
|
|
var zl *int32
|
|
if zoomLevel != nil {
|
|
z := int32(*zoomLevel)
|
|
zl = &z
|
|
}
|
|
do := int32(displayOrder)
|
|
return s.q.UpsertRegion(ctx, sqlc.UpsertRegionParams{
|
|
Slug: slug,
|
|
Name: name,
|
|
Description: &description,
|
|
DisplayOrder: &do,
|
|
CenterLat: centerLat,
|
|
CenterLng: centerLng,
|
|
ZoomLevel: zl,
|
|
})
|
|
}
|
|
|
|
// UpsertRegionIATA adds an IATA code to a region. Safe to call repeatedly.
|
|
func (s *Store) UpsertRegionIATA(ctx context.Context, regionID int32, iata string) error {
|
|
return s.q.UpsertRegionIATA(ctx, sqlc.UpsertRegionIATAParams{
|
|
RegionID: regionID,
|
|
Iata: iata,
|
|
})
|
|
}
|
|
|
|
// ListChannels returns a paginated list of channels ordered by last seen.
|
|
// Pass nil hash to skip hash filtering. Pass empty string iata to return channels from all IATAs.
|
|
// IATA filtering returns channels that have messages heard in the given IATA.
|
|
// Channels with unknown keys are included with KeyKnown=false.
|
|
// cursor is last_seen epoch ms of the last item; pass 0 to start from the beginning.
|
|
func (s *Store) ListChannels(ctx context.Context, limit int32, hash []byte, iata string, cursor int64) (api.Page[api.ChannelSummary], error) {
|
|
var cursorTS pgtype.Timestamptz
|
|
if cursor > 0 {
|
|
cursorTS = pgtype.Timestamptz{Time: time.UnixMilli(cursor), Valid: true}
|
|
}
|
|
rows, err := s.q.ListChannels(ctx, sqlc.ListChannelsParams{
|
|
Column1: hash,
|
|
Column2: iata,
|
|
Column3: cursorTS,
|
|
Limit: limit + 1,
|
|
})
|
|
if err != nil {
|
|
return api.Page[api.ChannelSummary]{}, err
|
|
}
|
|
hasMore := len(rows) > int(limit)
|
|
if hasMore {
|
|
rows = rows[:limit]
|
|
}
|
|
items := make([]api.ChannelSummary, 0, len(rows))
|
|
for _, v := range rows {
|
|
items = append(items, api.ChannelSummary{
|
|
ID: int(v.ID),
|
|
Name: v.Name,
|
|
ChannelHash: hex.EncodeToString(v.ChannelHash),
|
|
LastSeen: v.LastSeen.Time.UnixMilli(),
|
|
IsHashtag: v.IsHashtag != nil && *v.IsHashtag,
|
|
KeyKnown: v.KeyKnown != nil && *v.KeyKnown,
|
|
})
|
|
}
|
|
var nextCursor *int64
|
|
if hasMore {
|
|
last := items[len(items)-1].LastSeen
|
|
nextCursor = &last
|
|
}
|
|
return api.Page[api.ChannelSummary]{
|
|
Items: items,
|
|
NextCursor: nextCursor,
|
|
HasMore: hasMore,
|
|
}, nil
|
|
}
|
|
|
|
// GetChannel returns full detail for a single channel by its integer ID.
|
|
// Returns nil, pgx.ErrNoRows if the channel is not found.
|
|
func (s *Store) GetChannel(ctx context.Context, channelID int32) (*api.Channel, error) {
|
|
row, err := s.q.GetChannelByID(ctx, channelID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
channel := api.Channel{
|
|
ChannelSummary: api.ChannelSummary{
|
|
ID: int(row.ID),
|
|
Name: row.Name,
|
|
ChannelHash: hex.EncodeToString(row.ChannelHash),
|
|
LastSeen: row.LastSeen.Time.UnixMilli(),
|
|
IsHashtag: row.IsHashtag != nil && *row.IsHashtag,
|
|
KeyKnown: row.KeyKnown != nil && *row.KeyKnown,
|
|
},
|
|
Hashtag: row.Hashtag,
|
|
MessageCount: 0,
|
|
}
|
|
if row.MessageCount != nil {
|
|
channel.MessageCount = *row.MessageCount
|
|
}
|
|
if row.IsHashtag != nil && *row.IsHashtag && row.KeyFingerprint != nil {
|
|
fp := hex.EncodeToString(row.KeyFingerprint)
|
|
channel.KeyFingerprint = &fp
|
|
}
|
|
return &channel, nil
|
|
}
|
|
|
|
// ListChannelMessages returns paginated messages with optional channel ID, time, IATA and cursor filters.
|
|
// Pass nil channelID to return messages across all channels.
|
|
// Pass a zero time.Time for since to return all messages up to limit.
|
|
// Pass empty string iata to return messages from all IATAs.
|
|
// Pass cursor=0 to start from the beginning.
|
|
func (s *Store) ListChannelMessages(ctx context.Context, channelID *int32, since time.Time, limit int32, iata string, cursor int64) (api.Page[api.ChannelMessage], error) {
|
|
ts := pgtype.Timestamptz{Time: since, Valid: !since.IsZero()}
|
|
var messages []api.ChannelMessage
|
|
var hasMore bool
|
|
|
|
if channelID == nil {
|
|
rows, err := s.q.ListAllChannelMessages(ctx, sqlc.ListAllChannelMessagesParams{
|
|
Column1: ts,
|
|
Column2: iata,
|
|
Column3: cursor,
|
|
Limit: limit + 1,
|
|
})
|
|
if err != nil {
|
|
return api.Page[api.ChannelMessage]{}, err
|
|
}
|
|
hasMore = len(rows) > int(limit)
|
|
if hasMore {
|
|
rows = rows[:limit]
|
|
}
|
|
messages = make([]api.ChannelMessage, 0, len(rows))
|
|
for _, v := range rows {
|
|
messages = append(messages, toChannelMessage(v.ID, v.PacketHashHex, v.ChannelHash, v.SenderName, v.Content, v.SentAt))
|
|
}
|
|
} else {
|
|
rows, err := s.q.ListChannelMessages(ctx, sqlc.ListChannelMessagesParams{
|
|
ChannelID: *channelID,
|
|
Column2: ts,
|
|
Column3: iata,
|
|
Column4: cursor,
|
|
Limit: limit + 1,
|
|
})
|
|
if err != nil {
|
|
return api.Page[api.ChannelMessage]{}, err
|
|
}
|
|
hasMore = len(rows) > int(limit)
|
|
if hasMore {
|
|
rows = rows[:limit]
|
|
}
|
|
messages = make([]api.ChannelMessage, 0, len(rows))
|
|
for _, v := range rows {
|
|
messages = append(messages, toChannelMessage(v.ID, v.PacketHashHex, v.ChannelHash, v.SenderName, v.Content, v.SentAt))
|
|
}
|
|
}
|
|
|
|
var nextCursor *int64
|
|
if hasMore && len(messages) > 0 {
|
|
last := messages[len(messages)-1].ID
|
|
nextCursor = &last
|
|
}
|
|
return api.Page[api.ChannelMessage]{
|
|
Items: messages,
|
|
NextCursor: nextCursor,
|
|
HasMore: hasMore,
|
|
}, nil
|
|
}
|
|
|
|
// ListChannelMessagesByHash returns paginated messages for all channels matching the given hash.
|
|
// May return messages from multiple channels if the hash collides across different keys.
|
|
// Pass a zero time.Time for since to return all messages up to limit.
|
|
// Pass empty string iata to return messages from all IATAs.
|
|
// Pass cursor=0 to start from the beginning.
|
|
func (s *Store) ListChannelMessagesByHash(ctx context.Context, hash []byte, since time.Time, limit int32, iata string, cursor int64) (api.Page[api.ChannelMessage], error) {
|
|
rows, err := s.q.ListChannelMessagesByHash(ctx, sqlc.ListChannelMessagesByHashParams{
|
|
ChannelHash: hash,
|
|
Column2: pgtype.Timestamptz{Time: since, Valid: !since.IsZero()},
|
|
Column3: iata,
|
|
Column4: cursor,
|
|
Limit: limit + 1,
|
|
})
|
|
if err != nil {
|
|
return api.Page[api.ChannelMessage]{}, err
|
|
}
|
|
hasMore := len(rows) > int(limit)
|
|
if hasMore {
|
|
rows = rows[:limit]
|
|
}
|
|
messages := make([]api.ChannelMessage, 0, len(rows))
|
|
for _, v := range rows {
|
|
messages = append(messages, toChannelMessage(v.ID, hex.EncodeToString(v.PacketHash), v.ChannelHash, v.SenderName, v.Content, v.SentAt))
|
|
}
|
|
var nextCursor *int64
|
|
if hasMore && len(messages) > 0 {
|
|
last := messages[len(messages)-1].ID
|
|
nextCursor = &last
|
|
}
|
|
return api.Page[api.ChannelMessage]{
|
|
Items: messages,
|
|
NextCursor: nextCursor,
|
|
HasMore: hasMore,
|
|
}, nil
|
|
}
|
|
|
|
// ListObservers returns a summary list of observers with optional filters.
|
|
// All filter params are optional — pass empty string to skip a filter.
|
|
// status is "online" or "offline" derived from last_status_at recency.
|
|
// ListObservers returns a paginated list of observers with optional filters.
|
|
// cursor is last_seen epoch ms of the last observer; pass 0 to start from the beginning.
|
|
func (s *Store) ListObservers(ctx context.Context, iata, observerType, broker, status, name string, cursor int64, limit int32) (api.Page[api.ObserverSummary], error) {
|
|
var cursorTS pgtype.Timestamptz
|
|
if cursor > 0 {
|
|
cursorTS = pgtype.Timestamptz{Time: time.UnixMilli(cursor), Valid: true}
|
|
}
|
|
params := sqlc.ListObserversParams{
|
|
Column1: iata,
|
|
Column2: observerType,
|
|
Column3: broker,
|
|
Column4: status,
|
|
Column5: name,
|
|
Column6: cursorTS,
|
|
Limit: limit + 1,
|
|
}
|
|
rows, err := s.q.ListObservers(ctx, params)
|
|
if err != nil {
|
|
return api.Page[api.ObserverSummary]{}, err
|
|
}
|
|
hasMore := len(rows) > int(limit)
|
|
if hasMore {
|
|
rows = rows[:limit]
|
|
}
|
|
items := make([]api.ObserverSummary, 0, len(rows))
|
|
for _, v := range rows {
|
|
observer := api.ObserverSummary{
|
|
ID: v.ID,
|
|
IATA: v.Iata,
|
|
Status: v.Status,
|
|
}
|
|
if v.DisplayName != nil {
|
|
observer.DisplayName = v.DisplayName
|
|
}
|
|
if v.ObserverType != nil {
|
|
observer.ObserverType = v.ObserverType
|
|
}
|
|
items = append(items, observer)
|
|
}
|
|
var nextCursor *int64
|
|
if hasMore {
|
|
// observers use UUID so encode last_seen as cursor
|
|
if rows[len(rows)-1].LastStatusAt.Valid {
|
|
ms := rows[len(rows)-1].LastStatusAt.Time.UnixMilli()
|
|
nextCursor = &ms
|
|
}
|
|
}
|
|
return api.Page[api.ObserverSummary]{
|
|
Items: items,
|
|
NextCursor: nextCursor,
|
|
HasMore: hasMore,
|
|
}, nil
|
|
}
|
|
|
|
// GetObserver returns full detail for a single observer by UUID.
|
|
// Returns nil, pgx.ErrNoRows if the observer is not found.
|
|
func (s *Store) GetObserver(ctx context.Context, observerID uuid.UUID) (*api.Observer, error) {
|
|
obs, err := s.q.GetObserverByID(ctx, observerID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
brokerRows, err := s.q.GetObserverBrokers(ctx, observerID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
observer := api.Observer{
|
|
ObserverSummary: api.ObserverSummary{
|
|
ID: obs.ID,
|
|
DisplayName: obs.DisplayName,
|
|
ObserverType: obs.ObserverType,
|
|
Status: "offline",
|
|
},
|
|
PublicKey: hex.EncodeToString(obs.PublicKey),
|
|
SoftwareVersion: obs.SoftwareVersion,
|
|
HardwareModel: obs.HardwareModel,
|
|
FirmwareVersion: obs.FirmwareVersion,
|
|
FirmwareBuild: obs.FirmwareBuild,
|
|
RadioFreqMHz: obs.RadioFreqMhz,
|
|
RadioSF: obs.RadioSf,
|
|
RadioBWKHz: obs.RadioBwKhz,
|
|
RadioCR: obs.RadioCr,
|
|
BatteryLevel: obs.BatteryLevel,
|
|
UptimeSeconds: obs.UptimeSeconds,
|
|
StatusMetadata: obs.StatusMetadata,
|
|
FirstSeen: obs.FirstSeen.Time.UnixMilli(),
|
|
LastSeen: obs.LastSeen.Time.UnixMilli(),
|
|
ObservationCount: *obs.ObservationCount,
|
|
}
|
|
brokers := make([]api.ObserverBroker, 0, len(brokerRows))
|
|
for _, v := range brokerRows {
|
|
var lastPacketAt int64
|
|
if v.LastPacketAt.Valid {
|
|
lastPacketAt = v.LastPacketAt.Time.UnixMilli()
|
|
}
|
|
brokers = append(brokers, api.ObserverBroker{
|
|
Name: v.BrokerName,
|
|
LastPacketAt: lastPacketAt,
|
|
LastSeenAt: v.LastSeen.Time.UnixMilli(),
|
|
})
|
|
}
|
|
observer.Brokers = brokers
|
|
if obs.LastStatusAt.Valid && time.Since(obs.LastStatusAt.Time) < 5*time.Minute {
|
|
observer.Status = "online"
|
|
}
|
|
var lastStatusAt *int64
|
|
if obs.LastStatusAt.Valid {
|
|
ms := obs.LastStatusAt.Time.UnixMilli()
|
|
lastStatusAt = &ms
|
|
}
|
|
observer.LastStatusAt = lastStatusAt
|
|
observer.IATA, _ = s.GetObserverLastIATA(ctx, observerID)
|
|
return &observer, nil
|
|
}
|
|
|
|
func toChannelMessage(id int64, packetHashHex string, channelHash []byte, senderName *string, content *string, sentAt pgtype.Timestamptz) api.ChannelMessage {
|
|
sn := ""
|
|
if senderName != nil {
|
|
sn = *senderName
|
|
}
|
|
ct := ""
|
|
if content != nil {
|
|
ct = *content
|
|
}
|
|
return api.ChannelMessage{
|
|
ID: id,
|
|
PacketHash: packetHashHex,
|
|
ChannelHash: hex.EncodeToString(channelHash),
|
|
SenderName: sn,
|
|
Content: ct,
|
|
SentAt: sentAt.Time.UnixMilli(),
|
|
}
|
|
}
|
|
|
|
// InsertObserverTelemetry stores a telemetry snapshot for an observer.
|
|
// reportedAt should be truncated to the configured resolution before calling
|
|
// so that ON CONFLICT deduplicates within the resolution window.
|
|
func (s *Store) InsertObserverTelemetry(ctx context.Context, observerID uuid.UUID, reportedAt time.Time, batteryMV *int32, txAirSecs, rxAirSecs *float32, noiseFloor float32, uptimeSeconds int64, queueLen, debugFlags, recvErrors *int32) error {
|
|
return s.q.InsertObserverTelemetry(ctx, sqlc.InsertObserverTelemetryParams{
|
|
ObserverID: observerID,
|
|
ReportedAt: pgtype.Timestamptz{Time: reportedAt, Valid: true},
|
|
BatteryVoltageMv: batteryMV,
|
|
AirtimeTxPct: txAirSecs,
|
|
AirtimeRxPct: rxAirSecs,
|
|
NoiseFloorDb: &noiseFloor,
|
|
UptimeSeconds: &uptimeSeconds,
|
|
QueueLength: queueLen,
|
|
DebugFlags: debugFlags,
|
|
ReceiveErrors: recvErrors,
|
|
})
|
|
}
|
|
|
|
// DeleteOldTelemetry removes telemetry rows older than cutoff.
|
|
// Called by the cleanup goroutine in main.
|
|
func (s *Store) DeleteOldTelemetry(ctx context.Context, cutoff time.Time) error {
|
|
return s.q.DeleteOldTelemetry(ctx, pgtype.Timestamptz{Time: cutoff, Valid: true})
|
|
}
|
|
|
|
// DeleteOldPackets removes packets older than cutoff.
|
|
// packet_observations cascade-delete via FK constraint.
|
|
// Called by the cleanup goroutine in main.
|
|
func (s *Store) DeleteOldPackets(ctx context.Context, cutoff time.Time) error {
|
|
return s.q.DeleteOldPackets(ctx, pgtype.Timestamptz{Time: cutoff, Valid: true})
|
|
}
|
|
|
|
// RefreshHourlyStats refreshes the mv_hourly_iata_stats materialized view.
|
|
// Called by the cleanup goroutine to keep hourly observation stats current.
|
|
func (s *Store) RefreshHourlyStats(ctx context.Context) error {
|
|
return s.q.RefreshHourlyStats(ctx)
|
|
}
|
|
|
|
// RefreshTopNodes refreshes the mv_top_nodes_by_iata materialized view.
|
|
// Called by the cleanup goroutine to keep top node rankings current.
|
|
func (s *Store) RefreshTopNodes(ctx context.Context) error {
|
|
return s.q.RefreshTopNodes(ctx)
|
|
}
|
|
|
|
// GetObserverTelemetry returns telemetry points for an observer within the given time range.
|
|
// since and until define the window; pass zero times to use defaults (last 24h).
|
|
// TODO: implement server-side bucketing by interval when needed.
|
|
// Currently returns all points in the range at stored resolution.
|
|
func (s *Store) GetObserverTelemetry(ctx context.Context, observerID uuid.UUID, since, until time.Time, afterID int64) (*api.ObserverTelemetry, error) {
|
|
rows, err := s.q.GetObserverTelemetry(ctx, sqlc.GetObserverTelemetryParams{
|
|
ObserverID: observerID,
|
|
Column2: pgtype.Timestamptz{Time: since, Valid: !since.IsZero()},
|
|
Column3: pgtype.Timestamptz{Time: until, Valid: !until.IsZero()},
|
|
Column4: afterID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
points := make([]api.ObserverTelemetryPoint, 0, len(rows))
|
|
for _, v := range rows {
|
|
points = append(points, api.ObserverTelemetryPoint{
|
|
T: v.ReportedAt.Time.Unix(),
|
|
BatteryMV: v.BatteryVoltageMv,
|
|
AirtimeTxPct: v.AirtimeTxPct,
|
|
AirtimeRxPct: v.AirtimeRxPct,
|
|
NoiseFloorDB: v.NoiseFloorDb,
|
|
UptimeSeconds: v.UptimeSeconds,
|
|
QueueLength: v.QueueLength,
|
|
ReceiveErrors: v.ReceiveErrors,
|
|
})
|
|
}
|
|
return &api.ObserverTelemetry{Points: points}, nil
|
|
}
|
|
|
|
// ListObserverAdverts returns a paginated list of advert packets heard by an observer.
|
|
// Pass cursor=0 to start from the beginning.
|
|
func (s *Store) ListObserverAdverts(ctx context.Context, observerID uuid.UUID, cursor int64, limit int32) (api.Page[api.AdvertObservation], error) {
|
|
rows, err := s.q.ListObserverAdverts(ctx, sqlc.ListObserverAdvertsParams{
|
|
ObserverID: observerID,
|
|
Column2: cursor,
|
|
Limit: limit + 1, // fetch one extra to detect hasMore
|
|
})
|
|
if err != nil {
|
|
log.Printf("api: ListObserverAdverts failed: %v", err)
|
|
return api.Page[api.AdvertObservation]{}, err
|
|
}
|
|
hasMore := len(rows) > int(limit)
|
|
if hasMore {
|
|
rows = rows[:limit]
|
|
}
|
|
items := make([]api.AdvertObservation, 0, len(rows))
|
|
for _, v := range rows {
|
|
items = append(items, api.AdvertObservation{
|
|
PacketObservationSummary: api.PacketObservationSummary{
|
|
ID: v.ID,
|
|
PacketHash: v.PacketHashHex,
|
|
PayloadType: v.PayloadType,
|
|
PayloadTypeName: api.PayloadTypeName(v.PayloadType),
|
|
IATA: v.Iata,
|
|
HeardAt: v.HeardAt.Time.UnixMilli(),
|
|
RSSI: v.Rssi,
|
|
SNR: v.Snr,
|
|
HopCount: &v.HopCount,
|
|
},
|
|
NodeName: v.NodeName,
|
|
NodePublicKey: &v.NodePublicKey,
|
|
})
|
|
}
|
|
var nextCursor *int64
|
|
if hasMore {
|
|
last := items[len(items)-1].ID
|
|
nextCursor = &last
|
|
}
|
|
return api.Page[api.AdvertObservation]{
|
|
Items: items,
|
|
NextCursor: nextCursor,
|
|
HasMore: hasMore,
|
|
}, nil
|
|
}
|
|
|
|
// ListNodes returns a paginated list of nodes with optional filters.
|
|
// Pass 0 for nodeType, empty string for iata/name, nil for pubkey to skip those filters.
|
|
// cursor is last_seen epoch ms; pass 0 to start from the beginning.
|
|
func (s *Store) ListNodes(ctx context.Context, nodeType int16, iata string, supportsMultibytePaths, supportsMultibyteTraces bool, pubkey []byte, name string, cursor int64, limit int32) (api.Page[api.NodeSummary], error) {
|
|
var cursorTS pgtype.Timestamptz
|
|
if cursor > 0 {
|
|
cursorTS = pgtype.Timestamptz{Time: time.UnixMilli(cursor), Valid: true}
|
|
}
|
|
rows, err := s.q.ListNodes(ctx, sqlc.ListNodesParams{
|
|
Column1: nodeType,
|
|
Column2: iata,
|
|
Column3: supportsMultibytePaths,
|
|
Column4: supportsMultibyteTraces,
|
|
Column5: pubkey,
|
|
Column6: name,
|
|
Column7: cursorTS,
|
|
Limit: limit + 1,
|
|
})
|
|
if err != nil {
|
|
return api.Page[api.NodeSummary]{}, err
|
|
}
|
|
hasMore := len(rows) > int(limit)
|
|
if hasMore {
|
|
rows = rows[:limit]
|
|
}
|
|
items := make([]api.NodeSummary, 0, len(rows))
|
|
for _, v := range rows {
|
|
items = append(items, api.NodeSummary{
|
|
ID: v.ID,
|
|
PublicKey: hex.EncodeToString(v.PublicKey),
|
|
NodeType: v.NodeType,
|
|
NodeTypeName: api.NodeTypeName(v.NodeType),
|
|
Name: v.Name,
|
|
Latitude: v.Latitude,
|
|
Longitude: v.Longitude,
|
|
})
|
|
}
|
|
var nextCursor *int64
|
|
if hasMore && len(items) > 0 {
|
|
ms := rows[len(rows)-1].LastSeen.Time.UnixMilli()
|
|
nextCursor = &ms
|
|
}
|
|
return api.Page[api.NodeSummary]{
|
|
Items: items,
|
|
NextCursor: nextCursor,
|
|
HasMore: hasMore,
|
|
}, nil
|
|
}
|
|
|
|
// GetNode returns full detail for a single node by UUID.
|
|
// Returns nil, pgx.ErrNoRows if the node is not found.
|
|
func (s *Store) GetNode(ctx context.Context, nodeID uuid.UUID) (*api.Node, error) {
|
|
row, err := s.q.GetNodeByID(ctx, nodeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
node := &api.Node{
|
|
NodeSummary: api.NodeSummary{
|
|
ID: row.ID,
|
|
PublicKey: hex.EncodeToString(row.PublicKey),
|
|
NodeType: row.NodeType,
|
|
NodeTypeName: api.NodeTypeName(row.NodeType),
|
|
Name: row.Name,
|
|
Latitude: row.Latitude,
|
|
Longitude: row.Longitude,
|
|
},
|
|
LocationSource: row.LocationSource,
|
|
SupportsMultibytePaths: row.SupportsMultibytePaths,
|
|
SupportsMultibyteTraces: row.SupportsMultibyteTraces,
|
|
MinFirmwareVersion: row.MinFirmwareVersion,
|
|
FirstSeen: row.FirstSeen.Time.UnixMilli(),
|
|
LastSeen: row.LastSeen.Time.UnixMilli(),
|
|
Metadata: row.Metadata,
|
|
}
|
|
if row.LastAdvertAt.Valid {
|
|
ms := row.LastAdvertAt.Time.UnixMilli()
|
|
node.LastAdvertAt = &ms
|
|
}
|
|
return node, nil
|
|
}
|
|
|
|
func (s *Store) ListNodeObservations(ctx context.Context, nodeID uuid.UUID, cursor int64, limit int32) (api.Page[api.PacketObservationSummary], error) {
|
|
rows, err := s.q.ListNodeObservations(ctx, sqlc.ListNodeObservationsParams{
|
|
ID: nodeID,
|
|
Column2: cursor,
|
|
Limit: limit + 1,
|
|
})
|
|
if err != nil {
|
|
return api.Page[api.PacketObservationSummary]{}, err
|
|
}
|
|
hasMore := len(rows) > int(limit)
|
|
if hasMore {
|
|
rows = rows[:limit]
|
|
}
|
|
items := make([]api.PacketObservationSummary, 0, len(rows))
|
|
for _, v := range rows {
|
|
items = append(items, api.PacketObservationSummary{
|
|
ID: v.ID,
|
|
PacketHash: v.PacketHashHex,
|
|
PayloadType: v.PayloadType,
|
|
PayloadTypeName: api.PayloadTypeName(v.PayloadType),
|
|
IATA: v.Iata,
|
|
HeardAt: v.HeardAt.Time.UnixMilli(),
|
|
RSSI: v.Rssi,
|
|
SNR: v.Snr,
|
|
HopCount: &v.HopCount,
|
|
})
|
|
}
|
|
var nextCursor *int64
|
|
if hasMore && len(items) > 0 {
|
|
last := items[len(items)-1].ID
|
|
nextCursor = &last
|
|
}
|
|
return api.Page[api.PacketObservationSummary]{
|
|
Items: items,
|
|
NextCursor: nextCursor,
|
|
HasMore: hasMore,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Store) ListPackets(ctx context.Context, payloadType, routeType int16, iata string, since, until time.Time, cursor int64, limit int32) (api.Page[api.PacketSummary], error) {
|
|
var cursorTS pgtype.Timestamptz
|
|
if cursor > 0 {
|
|
cursorTS = pgtype.Timestamptz{Time: time.UnixMilli(cursor), Valid: true}
|
|
}
|
|
var sinceTS pgtype.Timestamptz
|
|
if !since.IsZero() {
|
|
sinceTS = pgtype.Timestamptz{Time: since, Valid: true}
|
|
}
|
|
var untilTS pgtype.Timestamptz
|
|
if !until.IsZero() {
|
|
untilTS = pgtype.Timestamptz{Time: until, Valid: true}
|
|
}
|
|
rows, err := s.q.ListPackets(ctx, sqlc.ListPacketsParams{
|
|
Column1: payloadType,
|
|
Column2: routeType,
|
|
Column3: iata,
|
|
Column4: sinceTS,
|
|
Column5: untilTS,
|
|
Column6: cursorTS,
|
|
Limit: limit + 1,
|
|
})
|
|
if err != nil {
|
|
return api.Page[api.PacketSummary]{}, err
|
|
}
|
|
hasMore := len(rows) > int(limit)
|
|
if hasMore {
|
|
rows = rows[:limit]
|
|
}
|
|
items := make([]api.PacketSummary, 0, len(rows))
|
|
for _, v := range rows {
|
|
item := api.PacketSummary{
|
|
PacketHash: hex.EncodeToString(v.PacketHash),
|
|
PayloadType: v.PayloadType,
|
|
PayloadTypeName: api.PayloadTypeName(v.PayloadType),
|
|
RouteType: v.RouteType,
|
|
RouteTypeName: api.RouteTypeName(v.RouteType),
|
|
FirstHeardAt: v.FirstHeardAt.Time.UnixMilli(),
|
|
LastHeardAt: v.LastHeardAt.Time.UnixMilli(),
|
|
ObservationCount: *v.ObservationCount,
|
|
}
|
|
if v.LatestObserverID != (uuid.UUID{}) {
|
|
item.LatestObserver = &api.PacketLatestObserver{
|
|
ID: v.LatestObserverID,
|
|
DisplayName: v.LatestObserverName,
|
|
IATA: v.LatestObserverIata,
|
|
}
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
var nextCursor *int64
|
|
if hasMore && len(items) > 0 {
|
|
last := items[len(items)-1].LastHeardAt
|
|
nextCursor = &last
|
|
}
|
|
return api.Page[api.PacketSummary]{
|
|
Items: items,
|
|
NextCursor: nextCursor,
|
|
HasMore: hasMore,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Store) GetPacket(ctx context.Context, packetHash []byte) (*api.Packet, error) {
|
|
row, err := s.q.GetPacketByHash(ctx, packetHash)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
obsRows, err := s.q.ListObservationsForPacket(ctx, packetHash)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p := &api.Packet{
|
|
PacketHash: hex.EncodeToString(row.PacketHash),
|
|
PayloadType: row.PayloadType,
|
|
PayloadTypeName: api.PayloadTypeName(row.PayloadType),
|
|
PayloadVersion: row.PayloadVersion,
|
|
RouteType: row.RouteType,
|
|
RouteTypeName: api.RouteTypeName(row.RouteType),
|
|
RawPayload: hex.EncodeToString(row.RawPayload),
|
|
ParsedPayload: row.ParsedPayload,
|
|
Decrypted: row.Decrypted != nil && *row.Decrypted,
|
|
FirstHeardAt: row.FirstHeardAt.Time.UnixMilli(),
|
|
LastHeardAt: row.LastHeardAt.Time.UnixMilli(),
|
|
Observations: make([]api.PacketObservationDetail, 0, len(obsRows)),
|
|
}
|
|
if row.ObservationCount != nil {
|
|
p.ObservationCount = *row.ObservationCount
|
|
}
|
|
if row.OriginPubkey != nil {
|
|
s := hex.EncodeToString(row.OriginPubkey)
|
|
p.OriginPubkey = &s
|
|
}
|
|
if row.ChannelHash != nil {
|
|
ch := hex.EncodeToString(row.ChannelHash)
|
|
p.ChannelHash = &ch
|
|
}
|
|
if row.TransportCodesPresent != nil && *row.TransportCodesPresent {
|
|
// TODO: decode transport codes from region_code/sub_region_code
|
|
}
|
|
for _, v := range obsRows {
|
|
obs := api.PacketObservationDetail{
|
|
ID: v.ID,
|
|
ObserverID: v.ObserverID,
|
|
ObserverName: v.ObserverName,
|
|
IATA: v.Iata,
|
|
HeardAt: v.HeardAt.Time.UnixMilli(),
|
|
PathLengthByte: v.PathLengthByte,
|
|
HashSize: v.HashSize,
|
|
HopCount: v.HopCount,
|
|
RSSI: v.Rssi,
|
|
SNR: v.Snr,
|
|
SourceBroker: *v.SourceBroker,
|
|
ResolvedPath: []api.ResolvedHop{}, // TODO: implement path resolution
|
|
}
|
|
if v.PathBytes != nil {
|
|
pb := hex.EncodeToString(v.PathBytes)
|
|
obs.PathBytes = &pb
|
|
}
|
|
if v.PropagationTimeMs != nil {
|
|
obs.PropagationTimeMs = v.PropagationTimeMs
|
|
}
|
|
if v.RadioFreqMhz != nil || v.SpreadFactor != nil || v.BandwidthKhz != nil || v.CodingRate != nil {
|
|
obs.Radio = &api.PacketRadio{
|
|
FreqMHz: v.RadioFreqMhz,
|
|
SpreadFactor: v.SpreadFactor,
|
|
BandwidthKHz: v.BandwidthKhz,
|
|
CodingRate: v.CodingRate,
|
|
}
|
|
}
|
|
p.Observations = append(p.Observations, obs)
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// GetStatsOverview returns top-line network figures for the last 24 hours.
|
|
func (s *Store) GetStatsOverview(ctx context.Context, iata string) (*api.StatsOverview, error) {
|
|
row, err := s.q.GetStatsOverview(ctx, iata)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &api.StatsOverview{
|
|
TotalPackets: row.TotalPackets,
|
|
TotalObservations: row.TotalObservations,
|
|
ActiveObservers: row.ActiveObservers,
|
|
ActiveIATAs: row.ActiveIatas,
|
|
WindowHours: 24,
|
|
}, nil
|
|
}
|
|
|
|
// GetStatsObservations returns hourly observation counts for charting.
|
|
func (s *Store) GetStatsObservations(ctx context.Context, iata string, since time.Time) ([]api.ObservationPoint, error) {
|
|
if since.IsZero() {
|
|
since = time.Now().Add(-7 * 24 * time.Hour)
|
|
}
|
|
interval := time.Since(since)
|
|
rows, err := s.q.GetHourlyStats(ctx, sqlc.GetHourlyStatsParams{
|
|
Column1: iata,
|
|
Column2: pgtype.Interval{Microseconds: int64(interval.Hours()) * 3600 * 1e6, Valid: true},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
points := make([]api.ObservationPoint, 0, len(rows))
|
|
for _, v := range rows {
|
|
points = append(points, api.ObservationPoint{
|
|
Hour: v.Hour.Time.UnixMilli(),
|
|
IATA: v.Iata,
|
|
ObservationCount: v.ObservationCount,
|
|
UniquePackets: v.UniquePackets,
|
|
ActiveObservers: v.ActiveObservers,
|
|
})
|
|
}
|
|
return points, nil
|
|
}
|
|
|
|
func (s *Store) GetStatsPayloadBreakdown(ctx context.Context, iata string, since time.Time) ([]api.PayloadBreakdownItem, error) {
|
|
if since.IsZero() {
|
|
since = time.Now().Add(-24 * time.Hour)
|
|
}
|
|
rows, err := s.q.GetStatsPayloadBreakdown(ctx, sqlc.GetStatsPayloadBreakdownParams{
|
|
HeardAt: pgtype.Timestamptz{Time: since, Valid: true},
|
|
Column2: iata,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]api.PayloadBreakdownItem, 0, len(rows))
|
|
for _, v := range rows {
|
|
items = append(items, api.PayloadBreakdownItem{
|
|
PayloadType: v.PayloadType,
|
|
PayloadTypeName: api.PayloadTypeName(v.PayloadType),
|
|
Count: v.Count,
|
|
})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (s *Store) GetStatsTopNodes(ctx context.Context, iata string, limit int32) ([]api.TopNode, error) {
|
|
rows, err := s.q.GetTopNodes(ctx, sqlc.GetTopNodesParams{
|
|
Column1: iata,
|
|
Limit: limit,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]api.TopNode, 0, len(rows))
|
|
for _, v := range rows {
|
|
var count int64
|
|
if v.ObservationCount != nil {
|
|
count = *v.ObservationCount
|
|
}
|
|
items = append(items, api.TopNode{
|
|
NodeID: v.NodeID,
|
|
NodeName: v.Name,
|
|
NodeType: v.NodeType,
|
|
NodeTypeName: api.NodeTypeName(v.NodeType),
|
|
IATA: v.Iata,
|
|
ObservationCount: count,
|
|
LastHeard: v.LastHeard.Time.UnixMilli(),
|
|
})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (s *Store) GetStatsTopObservers(ctx context.Context, iata string, since time.Time, limit int32) ([]api.TopObserver, error) {
|
|
if since.IsZero() {
|
|
since = time.Now().Add(-24 * time.Hour)
|
|
}
|
|
rows, err := s.q.GetStatsTopObservers(ctx, sqlc.GetStatsTopObserversParams{
|
|
HeardAt: pgtype.Timestamptz{Time: since, Valid: true},
|
|
Column2: iata,
|
|
Limit: limit,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]api.TopObserver, 0, len(rows))
|
|
for _, v := range rows {
|
|
iata, _ := v.Iata.(string)
|
|
items = append(items, api.TopObserver{
|
|
ObserverID: v.ID,
|
|
DisplayName: v.DisplayName,
|
|
ObserverType: v.ObserverType,
|
|
IATA: iata,
|
|
ObservationCount: v.ObservationCount,
|
|
})
|
|
}
|
|
return items, nil
|
|
}
|