meshtastic-map/src/index.js

1138 lines
38 KiB
JavaScript

require('./utils/logger');
const path = require('path');
const express = require('express');
const compression = require('compression');
const protobufjs = require("protobufjs");
const commandLineArgs = require("command-line-args");
const commandLineUsage = require("command-line-usage");
const cors = require('cors');
const statsRoutes = require('./stats.js');
// create prisma db client
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
// return big ints as string when using JSON.stringify
BigInt.prototype.toJSON = function() {
return this.toString();
}
const optionsList = [
{
name: 'help',
alias: 'h',
type: Boolean,
description: 'Display this usage guide.'
},
{
name: "port",
type: Number,
description: "Port to serve web ui and api from.",
},
];
// parse command line args
const options = commandLineArgs(optionsList);
// show help
if(options.help){
const usage = commandLineUsage([
{
header: 'Meshtastic Map',
content: 'A map of all Meshtastic nodes heard via MQTT.',
},
{
header: 'Options',
optionList: optionsList,
},
]);
console.log(usage);
return;
}
// get options and fallback to default values
const port = options["port"] ?? 8080;
// load protobufs
const root = new protobufjs.Root();
root.resolvePath = (origin, target) => path.join(__dirname, "protobufs", target);
root.loadSync('meshtastic/mqtt.proto');
const HardwareModel = root.lookupEnum("HardwareModel");
const Role = root.lookupEnum("Config.DeviceConfig.Role");
const RegionCode = root.lookupEnum("Config.LoRaConfig.RegionCode");
const ModemPreset = root.lookupEnum("Config.LoRaConfig.ModemPreset");
// appends extra info for node objects returned from api
function formatNodeInfo(node) {
return {
...node,
node_id_hex: "!" + node.node_id.toString(16),
hardware_model_name: HardwareModel.valuesById[node.hardware_model] ?? null,
role_name: Role.valuesById[node.role] ?? null,
region_name: RegionCode.valuesById[node.region] ?? null,
modem_preset_name: ModemPreset.valuesById[node.modem_preset] ?? null,
};
}
const app = express();
// enable compression
app.use(compression());
// Apply CORS only to API routes
app.use('/api', cors());
// serve files inside the public folder from /
app.use('/', express.static(path.join(__dirname, 'public')));
app.get('/', async (req, res) => {
res.sendFile(path.join(__dirname, 'public/index.html'));
});
// stats API in separate file
app.use('/api/v1/stats', statsRoutes);
app.get('/api', async (req, res) => {
const links = [
{
"path": "/api",
"description": "This page",
},
{
"path": "/api/v1/nodes",
"description": "All meshtastic nodes",
"params": {
"role": "Filter by role",
"hardware_model": "Filter by hardware model",
},
},
{
"path": "/api/v1/nodes/:nodeId",
"description": "A specific meshtastic node",
},
{
"path": "/api/v1/nodes/:nodeId/device-metrics",
"description": "Device metrics for a meshtastic node",
"params": {
"count": "How many results to return",
"time_from": "Only include metrics created after this unix timestamp (milliseconds)",
"time_to": "Only include metrics created before this unix timestamp (milliseconds)",
},
},
{
"path": "/api/v1/nodes/:nodeId/environment-metrics",
"description": "Environment metrics for a meshtastic node",
"params": {
"count": "How many results to return",
"time_from": "Only include metrics created after this unix timestamp (milliseconds)",
"time_to": "Only include metrics created before this unix timestamp (milliseconds)",
},
},
{
"path": "/api/v1/nodes/:nodeId/power-metrics",
"description": "Power metrics for a meshtastic node",
"params": {
"count": "How many results to return",
"time_from": "Only include metrics created after this unix timestamp (milliseconds)",
"time_to": "Only include metrics created before this unix timestamp (milliseconds)",
},
},
{
"path": "/api/v1/nodes/:nodeId/neighbours",
"description": "Neighbours for a meshtastic node",
},
{
"path": "/api/v1/nodes/:nodeId/traceroutes",
"description": "Trace routes for a meshtastic node",
},
{
"path": "/api/v1/traceroutes",
"description": "Recent traceroute edges across all nodes",
"params": {
"time_from": "Only include traceroutes updated after this unix timestamp (milliseconds)",
"time_to": "Only include traceroutes updated before this unix timestamp (milliseconds)"
}
},
{
"path": "/api/v1/connections",
"description": "Aggregated edges between nodes from traceroutes",
"params": {
"node_id": "Only include connections involving this node id",
"time_from": "Only include edges created after this unix timestamp (milliseconds)",
"time_to": "Only include edges created before this unix timestamp (milliseconds)"
}
},
{
"path": "/api/v1/nodes/:nodeId/position-history",
"description": "Position history for a meshtastic node",
"params": {
"time_from": "Only include positions created after this unix timestamp (milliseconds)",
"time_to": "Only include positions created before this unix timestamp (milliseconds)",
},
},
{
"path": "/api/v1/stats/hardware-models",
"description": "Database statistics about known hardware models",
},
{
"path": "/api/v1/text-messages",
"description": "Text messages",
"params": {
"to": "Only include messages to this node id",
"from": "Only include messages from this node id",
"channel_id": "Only include messages for this channel id",
"gateway_id": "Only include messages gated to mqtt by this node id",
"last_id": "Only include messages before or after this id, based on results order",
"count": "How many results to return",
"order": "Order to return results in: asc, desc",
},
},
{
"path": "/api/v1/text-messages/embed",
"description": "Text messages rendered as an embeddable HTML page.",
},
{
"path": "/api/v1/waypoints",
"description": "Waypoints",
},
];
const linksHtml = links.map((link) => {
var line = `<li>`;
line += `<a href="${link.path}">${link.path}</a> - ${link.description}`;
line += `<ul>`;
for(const paramKey in (link.params ?? [])){
const paramDescription = link.params[paramKey];
line += "<li>";
line += `?${paramKey}: ${paramDescription}`;
line += `</li>`;
}
line += `</ul>`;
return line;
}).join("");
res.send(`<b>API Docs</b><br/><ul>${linksHtml}</ul>`);
});
app.get('/api/v1/nodes', async (req, res) => {
try {
// get query params
const role = req.query.role ? parseInt(req.query.role) : undefined;
const hardwareModel = req.query.hardware_model ? parseInt(req.query.hardware_model) : undefined;
// get nodes from db
const nodes = await prisma.node.findMany({
where: {
role: role,
hardware_model: hardwareModel,
// Since we removed retention; only include nodes that have been updated in the last 30 days
updated_at: {
gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) // within last 30 days
}
},
});
const nodesWithInfo = [];
for(const node of nodes){
nodesWithInfo.push(formatNodeInfo(node));
}
res.json({
nodes: nodesWithInfo,
});
} catch(err) {
console.error(err);
res.status(500).json({
message: "Something went wrong, try again later.",
});
}
});
app.get('/api/v1/nodes/:nodeId', async (req, res) => {
try {
const nodeId = parseInt(req.params.nodeId);
// find node
const node = await prisma.node.findFirst({
where: {
node_id: nodeId,
},
});
// make sure node exists
if(!node){
res.status(404).json({
message: "Not Found",
});
return;
}
res.json({
node: formatNodeInfo(node),
});
} catch(err) {
console.error(err);
res.status(500).json({
message: "Something went wrong, try again later.",
});
}
});
app.get('/api/v1/nodes/:nodeId/device-metrics', async (req, res) => {
try {
const nodeId = parseInt(req.params.nodeId);
const count = req.query.count ? parseInt(req.query.count) : undefined;
const timeFrom = req.query.time_from ? parseInt(req.query.time_from) : undefined;
const timeTo = req.query.time_to ? parseInt(req.query.time_to) : undefined;
// find node
const node = await prisma.node.findFirst({
where: {
node_id: nodeId,
},
});
// make sure node exists
if(!node){
res.status(404).json({
message: "Not Found",
});
return;
}
// get latest device metrics
const deviceMetrics = await prisma.deviceMetric.findMany({
where: {
node_id: node.node_id,
created_at: {
gte: timeFrom ? new Date(timeFrom) : undefined,
lte: timeTo ? new Date(timeTo) : undefined,
},
},
orderBy: {
id: 'desc',
},
take: count,
});
res.json({
device_metrics: deviceMetrics,
});
} catch(err) {
console.error(err);
res.status(500).json({
message: "Something went wrong, try again later.",
});
}
});
app.get('/api/v1/nodes/:nodeId/environment-metrics', async (req, res) => {
try {
const nodeId = parseInt(req.params.nodeId);
const count = req.query.count ? parseInt(req.query.count) : undefined;
const timeFrom = req.query.time_from ? parseInt(req.query.time_from) : undefined;
const timeTo = req.query.time_to ? parseInt(req.query.time_to) : undefined;
// find node
const node = await prisma.node.findFirst({
where: {
node_id: nodeId,
},
});
// make sure node exists
if(!node){
res.status(404).json({
message: "Not Found",
});
return;
}
// get latest environment metrics
const environmentMetrics = await prisma.environmentMetric.findMany({
where: {
node_id: node.node_id,
created_at: {
gte: timeFrom ? new Date(timeFrom) : undefined,
lte: timeTo ? new Date(timeTo) : undefined,
},
},
orderBy: {
id: 'desc',
},
take: count,
});
res.json({
environment_metrics: environmentMetrics,
});
} catch(err) {
console.error(err);
res.status(500).json({
message: "Something went wrong, try again later.",
});
}
});
app.get('/api/v1/nodes/:nodeId/power-metrics', async (req, res) => {
try {
const nodeId = parseInt(req.params.nodeId);
const count = req.query.count ? parseInt(req.query.count) : undefined;
const timeFrom = req.query.time_from ? parseInt(req.query.time_from) : undefined;
const timeTo = req.query.time_to ? parseInt(req.query.time_to) : undefined;
// find node
const node = await prisma.node.findFirst({
where: {
node_id: nodeId,
},
});
// make sure node exists
if(!node){
res.status(404).json({
message: "Not Found",
});
return;
}
// get latest power metrics
const powerMetrics = await prisma.powerMetric.findMany({
where: {
node_id: node.node_id,
created_at: {
gte: timeFrom ? new Date(timeFrom) : undefined,
lte: timeTo ? new Date(timeTo) : undefined,
},
},
orderBy: {
id: 'desc',
},
take: count,
});
res.json({
power_metrics: powerMetrics,
});
} catch(err) {
console.error(err);
res.status(500).json({
message: "Something went wrong, try again later.",
});
}
});
app.get('/api/v1/nodes/:nodeId/mqtt-metrics', async (req, res) => {
try {
const nodeId = parseInt(req.params.nodeId);
// find node
const node = await prisma.node.findFirst({
where: {
node_id: nodeId,
},
});
// make sure node exists
if(!node){
res.status(404).json({
message: "Not Found",
});
return;
}
// get mqtt topics published to by this node
const queryResult = await prisma.$queryRaw`select mqtt_topic, count(*) as packet_count, max(created_at) as last_packet_at from service_envelopes where gateway_id = ${nodeId} group by mqtt_topic order by packet_count desc;`;
res.json({
mqtt_metrics: queryResult,
});
} catch(err) {
console.error(err);
res.status(500).json({
message: "Something went wrong, try again later.",
});
}
});
app.get('/api/v1/nodes/:nodeId/neighbours', async (req, res) => {
try {
const nodeId = parseInt(req.params.nodeId);
// find node
const node = await prisma.node.findFirst({
where: {
node_id: nodeId,
},
});
// make sure node exists
if(!node){
res.status(404).json({
message: "Not Found",
});
return;
}
// get nodes from db that have this node as a neighbour
const nodesThatHeardUs = await prisma.node.findMany({
where: {
neighbours: {
array_contains: {
node_id: Number(nodeId),
},
},
},
});
res.json({
nodes_that_we_heard: node.neighbours.map((neighbour) => {
return {
...neighbour,
updated_at: node.neighbours_updated_at,
};
}),
nodes_that_heard_us: nodesThatHeardUs.map((nodeThatHeardUs) => {
const neighbourInfo = nodeThatHeardUs.neighbours.find((neighbour) => neighbour.node_id.toString() === node.node_id.toString());
return {
node_id: Number(nodeThatHeardUs.node_id),
snr: neighbourInfo.snr,
updated_at: nodeThatHeardUs.neighbours_updated_at,
};
}),
});
} catch(err) {
console.error(err);
res.status(500).json({
message: "Something went wrong, try again later.",
});
}
});
app.get('/api/v1/nodes/:nodeId/traceroutes', async (req, res) => {
try {
const nodeId = parseInt(req.params.nodeId);
const count = req.query.count ? parseInt(req.query.count) : 10; // can't set to null because of $queryRaw
// find node
const node = await prisma.node.findFirst({
where: {
node_id: nodeId,
},
});
// make sure node exists
if(!node){
res.status(404).json({
message: "Not Found",
});
return;
}
// get latest traceroutes
// We want replies where want_response is false and it will be "to" the
// requester.
const traceroutes = await prisma.$queryRaw`SELECT * FROM traceroutes WHERE want_response = false and \`to\` = ${node.node_id} and gateway_id is not null order by id desc limit ${count}`;
res.json({
traceroutes: traceroutes.map((trace) => {
// ensure route is json array
if(typeof(trace.route) === "string"){
trace.route = JSON.parse(trace.route);
}
// ensure route_back is json array
if(typeof(trace.route_back) === "string"){
trace.route_back = JSON.parse(trace.route_back);
}
// ensure snr_towards is json array
if(typeof(trace.snr_towards) === "string"){
trace.snr_towards = JSON.parse(trace.snr_towards);
}
// ensure snr_back is json array
if(typeof(trace.snr_back) === "string"){
trace.snr_back = JSON.parse(trace.snr_back);
}
return trace;
}),
});
} catch(err) {
console.error(err);
res.status(500).json({
message: "Something went wrong, try again later.",
});
}
});
// Aggregated recent traceroute edges (global), filtered by updated_at
// Returns deduplicated edges with the latest SNR and timestamp.
// GET /api/v1/nodes/traceroutes?time_from=...&time_to=...
app.get('/api/v1/traceroutes', async (req, res) => {
try {
const timeFrom = req.query.time_from ? parseInt(req.query.time_from) : undefined;
const timeTo = req.query.time_to ? parseInt(req.query.time_to) : undefined;
// Pull recent traceroutes within the time window. We only want replies (want_response=false)
// and those that were actually gated to MQTT (gateway_id not null)
const traces = await prisma.traceRoute.findMany({
where: {
want_response: false,
gateway_id: { not: null },
updated_at: {
gte: timeFrom ? new Date(timeFrom) : undefined,
lte: timeTo ? new Date(timeTo) : undefined,
},
},
orderBy: { id: 'desc' },
take: 5000, // cap to keep response bounded; UI can page/adjust time window if needed
});
// Normalize JSON fields that may be strings (depending on driver)
const normalized = traces.map((t) => {
const trace = { ...t };
if (typeof trace.route === 'string') {
try { trace.route = JSON.parse(trace.route); } catch(_) {}
}
if (typeof trace.route_back === 'string') {
try { trace.route_back = JSON.parse(trace.route_back); } catch(_) {}
}
if (typeof trace.snr_towards === 'string') {
try { trace.snr_towards = JSON.parse(trace.snr_towards); } catch(_) {}
}
if (typeof trace.snr_back === 'string') {
try { trace.snr_back = JSON.parse(trace.snr_back); } catch(_) {}
}
return trace;
});
// Build edges from both forward (towards) and reverse (back) paths.
// Forward path: to → route[] → from, using snr_towards
// Reverse path: from → route_back[] → to, using snr_back
const edgeKey = (a, b) => `${String(a)}->${String(b)}`;
const edges = new Map();
function upsertEdgesFromPath(trace, pathNodes, pathSnrs) {
for (let i = 0; i < pathNodes.length - 1; i++) {
const hopFrom = pathNodes[i];
const hopTo = pathNodes[i + 1];
const snr = typeof (pathSnrs && pathSnrs[i]) === 'number' ? pathSnrs[i] : null;
// Skip edges without SNR data
if (snr === null) continue;
const key = edgeKey(hopFrom, hopTo);
const existing = edges.get(key);
if (!existing) {
edges.set(key, {
from: hopFrom,
to: hopTo,
snr: snr,
updated_at: trace.updated_at,
channel_id: trace.channel_id ?? null,
gateway_id: trace.gateway_id ?? null,
traceroute_from: trace.from, // original initiator
traceroute_to: trace.to, // original target
});
} else if (new Date(trace.updated_at) > new Date(existing.updated_at)) {
existing.snr = snr;
existing.updated_at = trace.updated_at;
existing.channel_id = trace.channel_id ?? existing.channel_id;
existing.gateway_id = trace.gateway_id ?? existing.gateway_id;
existing.traceroute_from = trace.from;
existing.traceroute_to = trace.to;
}
}
}
for (const tr of normalized) {
// Forward path
const forwardPath = [];
if (tr.to != null) forwardPath.push(Number(tr.to));
if (Array.isArray(tr.route)) {
for (const hop of tr.route) {
if (hop != null) forwardPath.push(Number(hop));
}
}
if (tr.from != null) forwardPath.push(Number(tr.from));
const forwardSnrs = Array.isArray(tr.snr_towards) ? tr.snr_towards : [];
upsertEdgesFromPath(tr, forwardPath, forwardSnrs);
// Reverse path
const reversePath = [];
if (tr.from != null) reversePath.push(Number(tr.from));
if (Array.isArray(tr.route_back)) {
for (const hop of tr.route_back) {
if (hop != null) reversePath.push(Number(hop));
}
}
if (tr.to != null) reversePath.push(Number(tr.to));
const reverseSnrs = Array.isArray(tr.snr_back) ? tr.snr_back : [];
upsertEdgesFromPath(tr, reversePath, reverseSnrs);
}
res.json({
traceroute_edges: Array.from(edges.values()),
});
} catch (err) {
console.error(err);
res.status(500).json({
message: "Something went wrong, try again later.",
});
}
});
// Aggregated edges endpoint
// GET /api/v1/connections?node_id=...&time_from=...&time_to=...
app.get('/api/v1/connections', async (req, res) => {
try {
const nodeId = req.query.node_id ? parseInt(req.query.node_id) : undefined;
const timeFrom = req.query.time_from ? parseInt(req.query.time_from) : undefined;
const timeTo = req.query.time_to ? parseInt(req.query.time_to) : undefined;
// Query edges from database
const edges = await prisma.edge.findMany({
where: {
created_at: {
...(timeFrom && { gte: new Date(timeFrom) }),
...(timeTo && { lte: new Date(timeTo) }),
},
// Only include edges where both nodes have positions
from_latitude: { not: null },
from_longitude: { not: null },
to_latitude: { not: null },
to_longitude: { not: null },
// If node_id is provided, filter edges where either from_node_id or to_node_id matches
...(nodeId !== undefined && {
OR: [
{ from_node_id: nodeId },
{ to_node_id: nodeId },
],
}),
},
orderBy: [
{ created_at: 'desc' },
{ packet_id: 'desc' },
],
});
// Collect all unique node IDs from edges
const nodeIds = new Set();
for (const edge of edges) {
nodeIds.add(edge.from_node_id);
nodeIds.add(edge.to_node_id);
}
// Fetch current positions for all nodes
const nodes = await prisma.node.findMany({
where: {
node_id: { in: Array.from(nodeIds) },
},
select: {
node_id: true,
latitude: true,
longitude: true,
},
});
// Create a map of current node positions
const nodePositions = new Map();
for (const node of nodes) {
nodePositions.set(node.node_id, {
latitude: node.latitude,
longitude: node.longitude,
});
}
// Filter edges: only include edges where both nodes are still at the same location
const validEdges = edges.filter(edge => {
const fromCurrentPos = nodePositions.get(edge.from_node_id);
const toCurrentPos = nodePositions.get(edge.to_node_id);
// Skip if either node doesn't exist or doesn't have a current position
if (!fromCurrentPos || !toCurrentPos ||
fromCurrentPos.latitude === null || fromCurrentPos.longitude === null ||
toCurrentPos.latitude === null || toCurrentPos.longitude === null) {
return false;
}
// Check if stored positions match current positions
const fromMatches = fromCurrentPos.latitude === edge.from_latitude &&
fromCurrentPos.longitude === edge.from_longitude;
const toMatches = toCurrentPos.latitude === edge.to_latitude &&
toCurrentPos.longitude === edge.to_longitude;
return fromMatches && toMatches;
});
// Normalize node pairs: always use min/max to treat A->B and B->A as same connection
const connectionsMap = new Map();
for (const edge of validEdges) {
const nodeA = edge.from_node_id < edge.to_node_id ? edge.from_node_id : edge.to_node_id;
const nodeB = edge.from_node_id < edge.to_node_id ? edge.to_node_id : edge.from_node_id;
const key = `${nodeA}-${nodeB}`;
if (!connectionsMap.has(key)) {
connectionsMap.set(key, {
node_a: nodeA,
node_b: nodeB,
direction_ab: [], // A -> B edges
direction_ba: [], // B -> A edges
});
}
const connection = connectionsMap.get(key);
const isAB = edge.from_node_id === nodeA;
// Add edge to appropriate direction
if (isAB) {
connection.direction_ab.push({
snr: edge.snr,
snr_db: edge.snr / 4, // Convert to dB
created_at: edge.created_at,
packet_id: edge.packet_id,
source: edge.source,
});
} else {
connection.direction_ba.push({
snr: edge.snr,
snr_db: edge.snr / 4,
created_at: edge.created_at,
packet_id: edge.packet_id,
source: edge.source,
});
}
}
// Aggregate each connection
const connections = Array.from(connectionsMap.values()).map(conn => {
// Deduplicate edges by packet_id for each direction (keep first occurrence, which is most recent)
const dedupeByPacketId = (edges) => {
const seen = new Set();
return edges.filter(edge => {
if (seen.has(edge.packet_id)) {
return false;
}
seen.add(edge.packet_id);
return true;
});
};
const deduplicatedAB = dedupeByPacketId(conn.direction_ab);
const deduplicatedBA = dedupeByPacketId(conn.direction_ba);
// Calculate average SNR for A->B (using deduplicated edges)
const avgSnrAB = deduplicatedAB.length > 0
? deduplicatedAB.reduce((sum, e) => sum + e.snr_db, 0) / deduplicatedAB.length
: null;
// Calculate average SNR for B->A (using deduplicated edges)
const avgSnrBA = deduplicatedBA.length > 0
? deduplicatedBA.reduce((sum, e) => sum + e.snr_db, 0) / deduplicatedBA.length
: null;
// Get last 5 edges for each direction (already sorted by created_at DESC, packet_id DESC, now deduplicated)
const last5AB = deduplicatedAB.slice(0, 5);
const last5BA = deduplicatedBA.slice(0, 5);
// Determine worst average SNR
const worstAvgSnrDb = [avgSnrAB, avgSnrBA]
.filter(v => v !== null)
.reduce((min, val) => val < min ? val : min, Infinity);
return {
node_a: conn.node_a,
node_b: conn.node_b,
direction_ab: {
avg_snr_db: avgSnrAB,
last_5_edges: last5AB,
total_count: deduplicatedAB.length, // Use deduplicated count
},
direction_ba: {
avg_snr_db: avgSnrBA,
last_5_edges: last5BA,
total_count: deduplicatedBA.length, // Use deduplicated count
},
worst_avg_snr_db: worstAvgSnrDb !== Infinity ? worstAvgSnrDb : null,
};
}).filter(conn => conn.worst_avg_snr_db !== null); // Only return connections with at least one direction
res.json({
connections: connections,
});
} catch (err) {
console.error(err);
res.status(500).json({
message: "Something went wrong, try again later.",
});
}
});
app.get('/api/v1/nodes/:nodeId/position-history', async (req, res) => {
try {
// defaults
const nowInMilliseconds = new Date().getTime();
const oneHourAgoInMilliseconds = new Date().getTime() - (3600 * 1000);
// get request params
const nodeId = parseInt(req.params.nodeId);
const timeFrom = req.query.time_from ? parseInt(req.query.time_from) : oneHourAgoInMilliseconds;
const timeTo = req.query.time_to ? parseInt(req.query.time_to) : nowInMilliseconds;
// find node
const node = await prisma.node.findFirst({
where: {
node_id: nodeId,
},
});
// make sure node exists
if(!node){
res.status(404).json({
message: "Not Found",
});
return;
}
const positions = await prisma.position.findMany({
where: {
node_id: nodeId,
created_at: {
gte: new Date(timeFrom),
lte: new Date(timeTo),
},
}
});
const mapReports = await prisma.mapReport.findMany({
where: {
node_id: nodeId,
created_at: {
gte: new Date(timeFrom),
lte: new Date(timeTo),
},
}
});
const positionHistory = []
positions.forEach((position) => {
positionHistory.push({
id: position.id,
node_id: position.node_id,
type: "position",
latitude: position.latitude,
longitude: position.longitude,
altitude: position.altitude,
gateway_id: position.gateway_id,
channel_id: position.channel_id,
created_at: position.created_at,
});
});
mapReports.forEach((mapReport) => {
positionHistory.push({
node_id: mapReport.node_id,
type: "map_report",
latitude: mapReport.latitude,
longitude: mapReport.longitude,
altitude: mapReport.altitude,
created_at: mapReport.created_at,
});
});
// sort oldest to newest
positionHistory.sort((a, b) => a.created_at - b.created_at);
res.json({
position_history: positionHistory,
});
} catch(err) {
console.error(err);
res.status(500).json({
message: "Something went wrong, try again later.",
});
}
});
app.get('/api/v1/text-messages', async (req, res) => {
try {
// get query params
const to = req.query.to ?? undefined;
const from = req.query.from ?? undefined;
const channelId = req.query.channel_id ?? undefined;
const gatewayId = req.query.gateway_id ?? undefined;
const directMessageNodeIds = req.query.direct_message_node_ids?.split(",") ?? undefined;
const lastId = req.query.last_id ? parseInt(req.query.last_id) : undefined;
const count = req.query.count ? parseInt(req.query.count) : 50;
const order = req.query.order ?? "asc";
// if direct message node ids are provided, there should be exactly two node ids
if(directMessageNodeIds !== undefined && directMessageNodeIds.length !== 2){
res.status(400).json({
message: "direct_message_node_ids requires 2 node ids separated by a comma.",
});
return;
}
// default where clauses that should always be used for filtering
var where = {
channel_id: channelId,
gateway_id: gatewayId,
// when ordered oldest to newest (asc), only get records after last id
// when ordered newest to oldest (desc), only get records before last id
id: order === "asc" ? {
gt: lastId,
} : {
lt: lastId,
},
};
// if direct message node ids are provided, we expect exactly 2 node ids
if(directMessageNodeIds !== undefined && directMessageNodeIds.length === 2){
// filter message by "to -> from" or "from -> to"
const [firstNodeId, secondNodeId] = directMessageNodeIds;
where = {
AND: where,
OR: [
{
to: firstNodeId,
from: secondNodeId,
},
{
to: secondNodeId,
from: firstNodeId,
},
],
};
} else {
// filter by to and from
where = {
...where,
to: to,
from: from,
};
}
// get text messages from db
const textMessages = await prisma.textMessage.findMany({
where: where,
orderBy: {
id: order,
},
take: count,
});
res.json({
text_messages: textMessages,
});
} catch(err) {
res.status(500).json({
message: "Something went wrong, try again later.",
});
}
});
app.get('/api/v1/text-messages/embed', async (req, res) => {
res.sendFile(path.join(__dirname, 'public/text-messages-embed.html'));
});
app.get('/api/v1/waypoints', async (req, res) => {
try {
// get waypoints from db
const waypoints = await prisma.waypoint.findMany({
orderBy: {
id: 'desc',
},
});
// ensure we only have the latest unique waypoints
// since ordered by newest first, older entries will be ignored
const uniqueWaypoints = [];
for(const waypoint of waypoints){
// skip if we already have a newer entry for this waypoint
if(uniqueWaypoints.find((w) => w.from === waypoint.from && w.waypoint_id === waypoint.waypoint_id)){
continue;
}
// first time seeing this waypoint, add to unique list
uniqueWaypoints.push(waypoint);
}
// we only want waypoints that haven't expired yet
const nonExpiredWayPoints = uniqueWaypoints.filter((waypoint) => {
const nowInSeconds = Math.floor(Date.now() / 1000);
return waypoint.expire >= nowInSeconds;
});
res.json({
waypoints: nonExpiredWayPoints,
});
} catch(err) {
res.status(500).json({
message: "Something went wrong, try again later.",
});
}
});
// start express server
const listener = app.listen(port, () => {
const port = listener.address().port;
console.log(`Server running at http://127.0.0.1:${port}`);
});
// Graceful shutdown handlers
function gracefulShutdown(signal) {
console.log(`Received ${signal}. Starting graceful shutdown...`);
// Stop accepting new connections
listener.close(async (err) => {
console.log('HTTP server closed');
await prisma.$disconnect();
console.log('Database connections closed');
console.log('Graceful shutdown completed');
process.exit(0);
});
}
// Handle SIGTERM (Docker, systemd, etc.)
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
// Handle SIGINT (Ctrl+C)
process.on('SIGINT', () => gracefulShutdown('SIGINT'));