mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-09-01 16:48:19 +00:00
+2
-2
@@ -110,7 +110,7 @@ func main() {
|
||||
log.Fatalf("migrations failed: %v", err)
|
||||
}
|
||||
|
||||
store := db.New(pool, resolved.ClockDriftThreshold)
|
||||
store := db.New(pool, resolved.ClockDriftThreshold, resolved.NodeStaleThreshold)
|
||||
|
||||
// ── Presence write coalescing ────────────────────────────────────────────
|
||||
// Ingest writes go through the coalescer; reads keep using the store.
|
||||
@@ -246,7 +246,7 @@ func main() {
|
||||
|
||||
scheduler := background.New([]background.Task{
|
||||
background.ViewRefreshTask(store, resolved.ViewRefreshInterval),
|
||||
background.CleanupTask(store, resolved.TelemetryRetention, resolved.PacketRetention, resolved.CleanupInterval),
|
||||
background.CleanupTask(store, resolved.TelemetryRetention, resolved.PacketRetention, resolved.NodeDeleteAfter, resolved.CleanupInterval),
|
||||
background.ReconfirmTask(store, resolved.ReconfirmInterval),
|
||||
})
|
||||
go scheduler.Start(ctx)
|
||||
|
||||
+10
-2
@@ -127,6 +127,7 @@ func (s *Store) ListNodes(ctx context.Context, nodeType int16, iatas []string, s
|
||||
ObserverID: nullableUUID(v.ObserverID),
|
||||
KnownNeighborCount: v.KnownNeighborCount,
|
||||
NeighborIDs: v.NeighborIds,
|
||||
Stale: v.LastSeen.Valid && v.LastSeen.Time.Before(time.Now().Add(-s.staleThreshold)),
|
||||
}
|
||||
if len(v.Iatas) > 0 {
|
||||
if err := json.Unmarshal(v.Iatas, &node.IATAs); err != nil {
|
||||
@@ -135,7 +136,7 @@ func (s *Store) ListNodes(ctx context.Context, nodeType int16, iatas []string, s
|
||||
}
|
||||
}
|
||||
if v.RadioFreqMhz != nil && v.RadioSf != nil && v.RadioBwKhz != nil {
|
||||
s := fmt.Sprintf("%.1f,%g,%d", *v.RadioFreqMhz, *v.RadioBwKhz, *v.RadioSf)
|
||||
s := fmt.Sprintf("%g,%g,%d", *v.RadioFreqMhz, *v.RadioBwKhz, *v.RadioSf)
|
||||
node.Radio = &s
|
||||
}
|
||||
items = append(items, node)
|
||||
@@ -170,6 +171,7 @@ func (s *Store) GetNode(ctx context.Context, nodeID uuid.UUID) (*api.Node, error
|
||||
ObserverID: nullableUUID(row.ObserverID),
|
||||
DefaultScope: row.DefaultScopeName,
|
||||
KnownNeighborCount: row.KnownNeighborCount,
|
||||
Stale: row.LastSeen.Valid && row.LastSeen.Time.Before(time.Now().Add(-s.staleThreshold)),
|
||||
},
|
||||
LocationSource: row.LocationSource,
|
||||
SupportsMultibytePaths: row.SupportsMultibytePaths,
|
||||
@@ -192,7 +194,7 @@ func (s *Store) GetNode(ctx context.Context, nodeID uuid.UUID) (*api.Node, error
|
||||
}
|
||||
}
|
||||
if row.RadioFreqMhz != nil && row.RadioSf != nil && row.RadioBwKhz != nil {
|
||||
s := fmt.Sprintf("%.1f,%g,%d", *row.RadioFreqMhz, *row.RadioBwKhz, *row.RadioSf)
|
||||
s := fmt.Sprintf("%g,%g,%d", *row.RadioFreqMhz, *row.RadioBwKhz, *row.RadioSf)
|
||||
node.Radio = &s
|
||||
}
|
||||
if row.LastAdvertAt.Valid {
|
||||
@@ -288,3 +290,9 @@ func (s *Store) GetNodeNeighbors(ctx context.Context, nodeID uuid.UUID) ([]api.N
|
||||
func (s *Store) ReconfirmNeighbors(ctx context.Context) error {
|
||||
return s.q.ReconfirmNeighbors(ctx)
|
||||
}
|
||||
|
||||
// DeleteOldNodes deletes nodes not seen since the given cutoff. See the DeleteOldNodes SQL
|
||||
// query for the observer_owners exclusion and known_routes caveat.
|
||||
func (s *Store) DeleteOldNodes(ctx context.Context, cutoff time.Time) error {
|
||||
return s.q.DeleteOldNodes(ctx, pgtype.Timestamptz{Time: cutoff, Valid: true})
|
||||
}
|
||||
|
||||
+84
-2
@@ -242,6 +242,42 @@ func TestListNodes_IATAsUnmarshal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListNodes_Stale(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mockdb.NewMockQuerier(ctrl)
|
||||
|
||||
staleID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
||||
freshID := uuid.MustParse("00000000-0000-0000-0000-000000000002")
|
||||
unmeasuredID := uuid.MustParse("00000000-0000-0000-0000-000000000003")
|
||||
|
||||
mock.EXPECT().
|
||||
ListNodes(gomock.Any(), gomock.Any()).
|
||||
Return([]sqlc.ListNodesRow{
|
||||
{ID: staleID, PublicKey: []byte{0x01}, LastSeen: pgtype.Timestamptz{Time: time.Now().Add(-48 * time.Hour), Valid: true}},
|
||||
{ID: freshID, PublicKey: []byte{0x02}, LastSeen: pgtype.Timestamptz{Time: time.Now(), Valid: true}},
|
||||
{ID: unmeasuredID, PublicKey: []byte{0x03}, LastSeen: pgtype.Timestamptz{Valid: false}},
|
||||
}, nil)
|
||||
|
||||
store := &Store{q: mock, staleThreshold: 24 * time.Hour}
|
||||
page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", "", 0, 10, false)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
byID := make(map[uuid.UUID]bool)
|
||||
for _, n := range page.Items {
|
||||
byID[n.ID] = n.Stale
|
||||
}
|
||||
if !byID[staleID] {
|
||||
t.Error("expected node last seen 48h ago to be stale with a 24h threshold")
|
||||
}
|
||||
if byID[freshID] {
|
||||
t.Error("expected node last seen just now to not be stale")
|
||||
}
|
||||
if byID[unmeasuredID] {
|
||||
t.Error("expected a node with no last_seen at all to not be stale")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListNodes_RadioStringFormatting(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mockdb.NewMockQuerier(ctrl)
|
||||
@@ -271,8 +307,8 @@ func TestListNodes_RadioStringFormatting(t *testing.T) {
|
||||
if page.Items[0].Radio == nil {
|
||||
t.Fatal("expected Radio to be set")
|
||||
}
|
||||
if *page.Items[0].Radio != "915.0,125,7" {
|
||||
t.Errorf("expected Radio 915.0,125,7, got %s", *page.Items[0].Radio)
|
||||
if *page.Items[0].Radio != "915,125,7" {
|
||||
t.Errorf("expected Radio 915,125,7, got %s", *page.Items[0].Radio)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,6 +375,36 @@ func TestGetNode_LastAdvertAtNil(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNode_Stale(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mockdb.NewMockQuerier(ctrl)
|
||||
|
||||
nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
||||
|
||||
mock.EXPECT().
|
||||
GetNodeByID(gomock.Any(), nodeID).
|
||||
Return(sqlc.GetNodeByIDRow{
|
||||
ID: nodeID,
|
||||
PublicKey: []byte{0x01},
|
||||
NodeType: 1, // companion -- Stale applies to every node type, unlike clock drift
|
||||
FirstSeen: pgtype.Timestamptz{Time: time.Now().Add(-72 * time.Hour), Valid: true},
|
||||
LastSeen: pgtype.Timestamptz{Time: time.Now().Add(-48 * time.Hour), Valid: true},
|
||||
}, nil)
|
||||
|
||||
mock.EXPECT().
|
||||
GetNodeNeighbors(gomock.Any(), nodeID).
|
||||
Return([]sqlc.GetNodeNeighborsRow{}, nil)
|
||||
|
||||
store := &Store{q: mock, staleThreshold: 24 * time.Hour}
|
||||
node, err := store.GetNode(context.Background(), nodeID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !node.Stale {
|
||||
t.Error("expected node last seen 48h ago to be stale with a 24h threshold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNode_ClockDrift_OutOfSync(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mockdb.NewMockQuerier(ctrl)
|
||||
@@ -624,3 +690,19 @@ func TestListNodes_ExcludeNeighbors_LeavesIDsNil(t *testing.T) {
|
||||
t.Errorf("expected NeighborIDs to stay nil when includeNeighbors is false, got %v", page.Items[0].NeighborIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteOldNodes(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mockdb.NewMockQuerier(ctrl)
|
||||
|
||||
cutoff := time.Now().Add(-30 * 24 * time.Hour)
|
||||
|
||||
mock.EXPECT().
|
||||
DeleteOldNodes(gomock.Any(), gomock.Eq(pgtype.Timestamptz{Time: cutoff, Valid: true})).
|
||||
Return(nil)
|
||||
|
||||
store := &Store{q: mock}
|
||||
if err := store.DeleteOldNodes(context.Background(), cutoff); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ func (s *Store) ListObservers(ctx context.Context, iatas []string, observerType,
|
||||
Scopes: v.Scopes,
|
||||
}
|
||||
if v.RadioFreqMhz != nil && v.RadioSf != nil && v.RadioBwKhz != nil {
|
||||
s := fmt.Sprintf("%.1f,%g,%d", *v.RadioFreqMhz, *v.RadioBwKhz, *v.RadioSf)
|
||||
s := fmt.Sprintf("%g,%g,%d", *v.RadioFreqMhz, *v.RadioBwKhz, *v.RadioSf)
|
||||
observer.Radio = &s
|
||||
}
|
||||
if v.DisplayName != nil {
|
||||
|
||||
@@ -128,8 +128,8 @@ func TestListObservers_RadioStringFormatting(t *testing.T) {
|
||||
if page.Items[0].Radio == nil {
|
||||
t.Fatal("expected Radio to be set")
|
||||
}
|
||||
if *page.Items[0].Radio != "915.0,125,7" {
|
||||
t.Errorf("expected Radio 915.0,125,7, got %s", *page.Items[0].Radio)
|
||||
if *page.Items[0].Radio != "915,125,7" {
|
||||
t.Errorf("expected Radio 915,125,7, got %s", *page.Items[0].Radio)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -509,6 +509,18 @@ LIMIT $6;
|
||||
-- packet_observations cascade-delete via FK.
|
||||
DELETE FROM packets WHERE last_heard_at < $1;
|
||||
|
||||
-- name: DeleteOldNodes :exec
|
||||
-- Deletes nodes not seen since the given cutoff. node_iatas and node_neighbors cascade-
|
||||
-- delete via FK. Excludes nodes referenced by observer_owners.owner_node_id -- that FK has
|
||||
-- no ON DELETE action, so deleting one directly would fail the whole statement anyway, and
|
||||
-- an operator manually recorded ownership for that node, so leave it alone even if stale.
|
||||
-- known_routes.node_ids is a plain UUID[] with no FK; a deleted node's id can be left
|
||||
-- dangling in old routes there, but ReconfirmTask already prunes stale/ambiguous routes
|
||||
-- periodically and will clean those up on its own schedule.
|
||||
DELETE FROM nodes
|
||||
WHERE last_seen < $1
|
||||
AND id NOT IN (SELECT owner_node_id FROM observer_owners WHERE owner_node_id IS NOT NULL);
|
||||
|
||||
-- name: DeleteOldChannelIATAs :exec
|
||||
-- Keeps the channel IATA filter in step with packet retention.
|
||||
DELETE FROM channel_iatas WHERE last_heard < $1;
|
||||
|
||||
@@ -57,6 +57,20 @@ func (mr *MockQuerierMockRecorder) DeleteOldChannelIATAs(ctx, lastHeard any) *go
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOldChannelIATAs", reflect.TypeOf((*MockQuerier)(nil).DeleteOldChannelIATAs), ctx, lastHeard)
|
||||
}
|
||||
|
||||
// DeleteOldNodes mocks base method.
|
||||
func (m *MockQuerier) DeleteOldNodes(ctx context.Context, lastSeen pgtype.Timestamptz) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DeleteOldNodes", ctx, lastSeen)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// DeleteOldNodes indicates an expected call of DeleteOldNodes.
|
||||
func (mr *MockQuerierMockRecorder) DeleteOldNodes(ctx, lastSeen any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOldNodes", reflect.TypeOf((*MockQuerier)(nil).DeleteOldNodes), ctx, lastSeen)
|
||||
}
|
||||
|
||||
// DeleteOldPackets mocks base method.
|
||||
func (m *MockQuerier) DeleteOldPackets(ctx context.Context, lastHeardAt pgtype.Timestamptz) error {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -14,6 +14,14 @@ import (
|
||||
type Querier interface {
|
||||
// Keeps the channel IATA filter in step with packet retention.
|
||||
DeleteOldChannelIATAs(ctx context.Context, lastHeard pgtype.Timestamptz) error
|
||||
// Deletes nodes not seen since the given cutoff. node_iatas and node_neighbors cascade-
|
||||
// delete via FK. Excludes nodes referenced by observer_owners.owner_node_id -- that FK has
|
||||
// no ON DELETE action, so deleting one directly would fail the whole statement anyway, and
|
||||
// an operator manually recorded ownership for that node, so leave it alone even if stale.
|
||||
// known_routes.node_ids is a plain UUID[] with no FK; a deleted node's id can be left
|
||||
// dangling in old routes there, but ReconfirmTask already prunes stale/ambiguous routes
|
||||
// periodically and will clean those up on its own schedule.
|
||||
DeleteOldNodes(ctx context.Context, lastSeen pgtype.Timestamptz) error
|
||||
// Deletes packets and their observations older than the given cutoff.
|
||||
// packet_observations cascade-delete via FK.
|
||||
DeleteOldPackets(ctx context.Context, lastHeardAt pgtype.Timestamptz) error
|
||||
|
||||
@@ -22,6 +22,24 @@ func (q *Queries) DeleteOldChannelIATAs(ctx context.Context, lastHeard pgtype.Ti
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteOldNodes = `-- name: DeleteOldNodes :exec
|
||||
DELETE FROM nodes
|
||||
WHERE last_seen < $1
|
||||
AND id NOT IN (SELECT owner_node_id FROM observer_owners WHERE owner_node_id IS NOT NULL)
|
||||
`
|
||||
|
||||
// Deletes nodes not seen since the given cutoff. node_iatas and node_neighbors cascade-
|
||||
// delete via FK. Excludes nodes referenced by observer_owners.owner_node_id -- that FK has
|
||||
// no ON DELETE action, so deleting one directly would fail the whole statement anyway, and
|
||||
// an operator manually recorded ownership for that node, so leave it alone even if stale.
|
||||
// known_routes.node_ids is a plain UUID[] with no FK; a deleted node's id can be left
|
||||
// dangling in old routes there, but ReconfirmTask already prunes stale/ambiguous routes
|
||||
// periodically and will clean those up on its own schedule.
|
||||
func (q *Queries) DeleteOldNodes(ctx context.Context, lastSeen pgtype.Timestamptz) error {
|
||||
_, err := q.db.Exec(ctx, deleteOldNodes, lastSeen)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteOldPackets = `-- name: DeleteOldPackets :exec
|
||||
DELETE FROM packets WHERE last_heard_at < $1
|
||||
`
|
||||
|
||||
+5
-2
@@ -23,13 +23,16 @@ import (
|
||||
type Store struct {
|
||||
q sqlc.Querier
|
||||
clockDriftThreshold time.Duration // see api.Node.ClockOutOfSync
|
||||
staleThreshold time.Duration // see api.NodeSummary.Stale
|
||||
}
|
||||
|
||||
// New creates a Store backed by the given pgxpool connection pool. clockDriftThreshold is
|
||||
// the |device clock - server clock| magnitude above which a repeater/room server's
|
||||
// clockOutOfSync is reported true; see internal/config.ResolvedConfig.ClockDriftThreshold.
|
||||
func New(pool *pgxpool.Pool, clockDriftThreshold time.Duration) *Store {
|
||||
return &Store{q: sqlc.New(pool), clockDriftThreshold: clockDriftThreshold}
|
||||
// staleThreshold is how long since last_seen before a node's Stale is reported true; see
|
||||
// internal/config.ResolvedConfig.NodeStaleThreshold.
|
||||
func New(pool *pgxpool.Pool, clockDriftThreshold, staleThreshold time.Duration) *Store {
|
||||
return &Store{q: sqlc.New(pool), clockDriftThreshold: clockDriftThreshold, staleThreshold: staleThreshold}
|
||||
}
|
||||
|
||||
func (s *Store) ResolvePathHashes(ctx context.Context, iata string, hashes [][]byte) (map[string][]api.ResolvedPathEntry, error) {
|
||||
|
||||
+53
-8
@@ -4,11 +4,11 @@ package docs
|
||||
import "github.com/swaggo/swag"
|
||||
|
||||
const docTemplate = `{
|
||||
"schemes": {{ marshal .Schemes }},
|
||||
"schemes": [[ marshal .Schemes ]],
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"description": "{{escape .Description}}",
|
||||
"title": "{{.Title}}",
|
||||
"description": "[[escape .Description]]",
|
||||
"title": "[[.Title]]",
|
||||
"termsOfService": "https://github.com/MeshCore-Beacon/beacon-server",
|
||||
"contact": {
|
||||
"name": "MeshCore Beacon",
|
||||
@@ -17,10 +17,10 @@ const docTemplate = `{
|
||||
"license": {
|
||||
"name": "AGPL-3-or-later"
|
||||
},
|
||||
"version": "{{.Version}}"
|
||||
"version": "[[.Version]]"
|
||||
},
|
||||
"host": "{{.Host}}",
|
||||
"basePath": "{{.BasePath}}",
|
||||
"host": "[[.Host]]",
|
||||
"basePath": "[[.BasePath]]",
|
||||
"paths": {
|
||||
"/brokers": {
|
||||
"get": {
|
||||
@@ -291,6 +291,43 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/iatas/{iata}/border": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"IATAs"
|
||||
],
|
||||
"summary": "Get an IATA's GeoJSON border, if configured",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "3-letter IATA code",
|
||||
"name": "iata",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "GeoJSON Feature (Polygon or MultiPolygon geometry, with bbox)",
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"204": {
|
||||
"description": "IATA exists but has no border configured"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/messages": {
|
||||
"get": {
|
||||
"produces": [
|
||||
@@ -2539,6 +2576,10 @@ const docTemplate = `{
|
||||
"description": "shorthand: \"freqMhz,bwKhz,sf\" e.g. \"910.525,62.5,7\"",
|
||||
"type": "string"
|
||||
},
|
||||
"stale": {
|
||||
"description": "Stale is true when the node hasn't been seen (last_seen) within the configured\nstaleness window (default 24h; internal/config.ResolvedConfig.NodeStaleThreshold).\nApplies to every node type, unlike ClockDriftSeconds/ClockOutOfSync on Node, which\nare repeater/room-server only.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"supportsMultibytePaths": {
|
||||
"description": "firmware \u003e= 1.14.0; detected via path hash size",
|
||||
"type": "boolean"
|
||||
@@ -2664,6 +2705,10 @@ const docTemplate = `{
|
||||
"radio": {
|
||||
"description": "shorthand: \"freqMhz,bwKhz,sf\" e.g. \"910.525,62.5,7\"",
|
||||
"type": "string"
|
||||
},
|
||||
"stale": {
|
||||
"description": "Stale is true when the node hasn't been seen (last_seen) within the configured\nstaleness window (default 24h; internal/config.ResolvedConfig.NodeStaleThreshold).\nApplies to every node type, unlike ClockDriftSeconds/ClockOutOfSync on Node, which\nare repeater/room-server only.",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3774,8 +3819,8 @@ var SwaggerInfo = &swag.Spec{
|
||||
Description: "MeshCore network observation backend. Ingests LoRa packets from MQTT brokers, stores in PostgreSQL, and streams live events via WebSocket.",
|
||||
InfoInstanceName: "swagger",
|
||||
SwaggerTemplate: docTemplate,
|
||||
LeftDelim: "{{",
|
||||
RightDelim: "}}",
|
||||
LeftDelim: "[[",
|
||||
RightDelim: "]]",
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -289,6 +289,43 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/iatas/{iata}/border": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"IATAs"
|
||||
],
|
||||
"summary": "Get an IATA's GeoJSON border, if configured",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "3-letter IATA code",
|
||||
"name": "iata",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "GeoJSON Feature (Polygon or MultiPolygon geometry, with bbox)",
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"204": {
|
||||
"description": "IATA exists but has no border configured"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/messages": {
|
||||
"get": {
|
||||
"produces": [
|
||||
@@ -2537,6 +2574,10 @@
|
||||
"description": "shorthand: \"freqMhz,bwKhz,sf\" e.g. \"910.525,62.5,7\"",
|
||||
"type": "string"
|
||||
},
|
||||
"stale": {
|
||||
"description": "Stale is true when the node hasn't been seen (last_seen) within the configured\nstaleness window (default 24h; internal/config.ResolvedConfig.NodeStaleThreshold).\nApplies to every node type, unlike ClockDriftSeconds/ClockOutOfSync on Node, which\nare repeater/room-server only.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"supportsMultibytePaths": {
|
||||
"description": "firmware \u003e= 1.14.0; detected via path hash size",
|
||||
"type": "boolean"
|
||||
@@ -2662,6 +2703,10 @@
|
||||
"radio": {
|
||||
"description": "shorthand: \"freqMhz,bwKhz,sf\" e.g. \"910.525,62.5,7\"",
|
||||
"type": "string"
|
||||
},
|
||||
"stale": {
|
||||
"description": "Stale is true when the node hasn't been seen (last_seen) within the configured\nstaleness window (default 24h; internal/config.ResolvedConfig.NodeStaleThreshold).\nApplies to every node type, unlike ClockDriftSeconds/ClockOutOfSync on Node, which\nare repeater/room-server only.",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -248,6 +248,13 @@ definitions:
|
||||
radio:
|
||||
description: 'shorthand: "freqMhz,bwKhz,sf" e.g. "910.525,62.5,7"'
|
||||
type: string
|
||||
stale:
|
||||
description: |-
|
||||
Stale is true when the node hasn't been seen (last_seen) within the configured
|
||||
staleness window (default 24h; internal/config.ResolvedConfig.NodeStaleThreshold).
|
||||
Applies to every node type, unlike ClockDriftSeconds/ClockOutOfSync on Node, which
|
||||
are repeater/room-server only.
|
||||
type: boolean
|
||||
supportsMultibytePaths:
|
||||
description: firmware >= 1.14.0; detected via path hash size
|
||||
type: boolean
|
||||
@@ -336,6 +343,13 @@ definitions:
|
||||
radio:
|
||||
description: 'shorthand: "freqMhz,bwKhz,sf" e.g. "910.525,62.5,7"'
|
||||
type: string
|
||||
stale:
|
||||
description: |-
|
||||
Stale is true when the node hasn't been seen (last_seen) within the configured
|
||||
staleness window (default 24h; internal/config.ResolvedConfig.NodeStaleThreshold).
|
||||
Applies to every node type, unlike ClockDriftSeconds/ClockOutOfSync on Node, which
|
||||
are repeater/room-server only.
|
||||
type: boolean
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.NodeTypeCount:
|
||||
properties:
|
||||
@@ -1253,6 +1267,30 @@ paths:
|
||||
summary: Get a single IATA code
|
||||
tags:
|
||||
- IATAs
|
||||
/iatas/{iata}/border:
|
||||
get:
|
||||
parameters:
|
||||
- description: 3-letter IATA code
|
||||
in: path
|
||||
name: iata
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: GeoJSON Feature (Polygon or MultiPolygon geometry, with bbox)
|
||||
schema:
|
||||
type: object
|
||||
"204":
|
||||
description: IATA exists but has no border configured
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
summary: Get an IATA's GeoJSON border, if configured
|
||||
tags:
|
||||
- IATAs
|
||||
/messages:
|
||||
get:
|
||||
parameters:
|
||||
|
||||
@@ -48,6 +48,11 @@ type NodeSummary struct {
|
||||
DefaultScope *string `json:"defaultScope,omitempty"` // most recently matched transport scope name e.g. "#bc"
|
||||
KnownNeighborCount int64 `json:"knownNeighborCount"`
|
||||
NeighborIDs []uuid.UUID `json:"neighborIds,omitempty"` // only populated when the list request opts in; see ?neighbors=true
|
||||
// Stale is true when the node hasn't been seen (last_seen) within the configured
|
||||
// staleness window (default 24h; internal/config.ResolvedConfig.NodeStaleThreshold).
|
||||
// Applies to every node type, unlike ClockDriftSeconds/ClockOutOfSync on Node, which
|
||||
// are repeater/room-server only.
|
||||
Stale bool `json:"stale"`
|
||||
}
|
||||
|
||||
// Node is the full node representation including firmware capability flags,
|
||||
|
||||
@@ -44,8 +44,8 @@ func ViewRefreshTask(store *db.Store, interval time.Duration) Task {
|
||||
}
|
||||
}
|
||||
|
||||
// CleanupTask returns a Task that prunes old telemetry and packet rows.
|
||||
func CleanupTask(store *db.Store, telemetryRetention, packetRetention, interval time.Duration) Task {
|
||||
// CleanupTask returns a Task that prunes old telemetry, packet, and node rows.
|
||||
func CleanupTask(store *db.Store, telemetryRetention, packetRetention, nodeDeleteAfter, interval time.Duration) Task {
|
||||
return Task{
|
||||
Name: "cleanup",
|
||||
Interval: interval,
|
||||
@@ -65,6 +65,9 @@ func CleanupTask(store *db.Store, telemetryRetention, packetRetention, interval
|
||||
if err := store.DeleteOldTraceIATAs(ctx, cutoff); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := store.DeleteOldNodes(ctx, time.Now().Add(-nodeDeleteAfter)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -48,6 +48,11 @@ type ResolvedConfig struct {
|
||||
// node's ADVERT timestamp, above which the node API reports clockOutOfSync=true for
|
||||
// that node. Only meaningful for repeaters/room servers (nodeType 2/3).
|
||||
ClockDriftThreshold time.Duration
|
||||
|
||||
// NodeStaleThreshold and NodeDeleteAfter mirror ClockDriftThreshold's "0 means unset,
|
||||
// resolve to a default" pattern -- see NodesConfig.
|
||||
NodeStaleThreshold time.Duration
|
||||
NodeDeleteAfter time.Duration
|
||||
}
|
||||
|
||||
// PresenceConfig controls coalescing of presence bookkeeping writes
|
||||
@@ -178,6 +183,13 @@ type NodesConfig struct {
|
||||
// repeater/room server's ADVERT timestamp, above which the node API reports
|
||||
// clockOutOfSync=true for that node. Defaults to 5m if not set.
|
||||
ClockDriftThreshold duration `yaml:"clock_drift_threshold"`
|
||||
// StaleThreshold is how long since a node's last_seen before the node API reports
|
||||
// stale=true for it. Defaults to 24h if not set.
|
||||
StaleThreshold duration `yaml:"stale_threshold"`
|
||||
// DeleteAfter is how long since a node's last_seen before the cleanup job deletes the
|
||||
// node entirely. Defaults to the same 30-day default as packets.retention if not set --
|
||||
// independently configurable from it, just the same starting point.
|
||||
DeleteAfter duration `yaml:"delete_after"`
|
||||
}
|
||||
|
||||
// duration is a wrapper around time.Duration that supports YAML unmarshalling
|
||||
@@ -299,6 +311,8 @@ func Resolve(cfg *Config) ResolvedConfig {
|
||||
PresencePacketTTL: cfg.Presence.PacketTTL.Duration,
|
||||
|
||||
ClockDriftThreshold: cfg.Nodes.ClockDriftThreshold.Duration,
|
||||
NodeStaleThreshold: cfg.Nodes.StaleThreshold.Duration,
|
||||
NodeDeleteAfter: cfg.Nodes.DeleteAfter.Duration,
|
||||
}
|
||||
if r.TelemetryResolution == 0 {
|
||||
r.TelemetryResolution = time.Hour
|
||||
@@ -330,14 +344,23 @@ func Resolve(cfg *Config) ResolvedConfig {
|
||||
if r.ClockDriftThreshold == 0 {
|
||||
r.ClockDriftThreshold = 5 * time.Minute
|
||||
}
|
||||
if r.NodeStaleThreshold == 0 {
|
||||
r.NodeStaleThreshold = 24 * time.Hour
|
||||
}
|
||||
if r.NodeDeleteAfter == 0 {
|
||||
// Same default as packets.retention (30 days) -- independently configurable, just
|
||||
// the same starting point, not tied to whatever PacketRetention resolves to.
|
||||
r.NodeDeleteAfter = 30 * 24 * time.Hour
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (r ResolvedConfig) String() string {
|
||||
return fmt.Sprintf(
|
||||
"telemetryResolution=%s telemetryRetention=%s packetRetention=%s maxConnsPerIP=%d viewRefresh=%s reconfirm=%s cleanup=%s presenceFlush=%s presencePacketTTL=%s clockDriftThreshold=%s",
|
||||
"telemetryResolution=%s telemetryRetention=%s packetRetention=%s maxConnsPerIP=%d viewRefresh=%s reconfirm=%s cleanup=%s presenceFlush=%s presencePacketTTL=%s clockDriftThreshold=%s nodeStaleThreshold=%s nodeDeleteAfter=%s",
|
||||
r.TelemetryResolution, r.TelemetryRetention, r.PacketRetention,
|
||||
r.MaxConnsPerIP, r.ViewRefreshInterval, r.ReconfirmInterval, r.CleanupInterval,
|
||||
r.PresenceFlushInterval, r.PresencePacketTTL, r.ClockDriftThreshold,
|
||||
r.NodeStaleThreshold, r.NodeDeleteAfter,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -98,6 +98,12 @@ func TestResolve_Defaults(t *testing.T) {
|
||||
if r.ClockDriftThreshold != 5*time.Minute {
|
||||
t.Errorf("expected ClockDriftThreshold 5m, got %v", r.ClockDriftThreshold)
|
||||
}
|
||||
if r.NodeStaleThreshold != 24*time.Hour {
|
||||
t.Errorf("expected NodeStaleThreshold 24h, got %v", r.NodeStaleThreshold)
|
||||
}
|
||||
if r.NodeDeleteAfter != 30*24*time.Hour {
|
||||
t.Errorf("expected NodeDeleteAfter 720h (same default as PacketRetention), got %v", r.NodeDeleteAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_ExplicitValues(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user