diff --git a/api/rollup.config.mjs b/api/rollup.config.mjs index 1be0c69..2895810 100644 --- a/api/rollup.config.mjs +++ b/api/rollup.config.mjs @@ -74,8 +74,9 @@ export default defineConfig({ ignoreGlobal: true }), typescript({ - target: 'esnext' + target: 'esnext', // sourceMap: true + exclude: ['webbluetooth/build/**'] }), json() ] diff --git a/api/src/index.ts b/api/src/index.ts index 2d79146..e0040d2 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -1,3 +1,13 @@ +import { webcrypto } from 'crypto'; + +declare global { + // Let TypeScript know about globalThis.crypto + // eslint-disable-next-line no-var + var crypto: typeof webcrypto; +} + +globalThis.crypto = webcrypto; + import 'dotenv/config' import './lib/persistence' import { app, createRoutes, finalize, server } from './lib/server' @@ -10,6 +20,8 @@ import { createWriteStream } from 'fs' import { dataDirectory } from './lib/paths' import { join } from 'path' import axios from 'axios' +import express from 'express'; +import { nodeHistoryMap } from './nodeHistoryStore'; setInterval(() => currentTime.set(Date.now()), 15000) process.on('uncaughtException', (err, origin) => { @@ -72,6 +84,7 @@ createRoutes((app) => { if (!isAuthorized(req)) return res.sendStatus(403) let nodes = req.body.nodes await deleteNodes(nodes) + return res.sendStatus(200) }) app.post('/connect', async (req, res) => { @@ -105,6 +118,15 @@ createRoutes((app) => { return res.sendStatus(200) }) + app.get('/api/nodes/:nodeNum/history', (req, res) => { + const nodeNum = Number(req.params.nodeNum); // Ensure this is a number + if (isNaN(nodeNum)) { + return res.status(400).json({ error: 'Invalid nodeNum' }); + } + const history = nodeHistoryMap.get(nodeNum) || []; + return res.json(history); + }); + //** Set accessKey via environment variable */ if (process.env.ACCESS_KEY) { accessKey.set(process.env.ACCESS_KEY) diff --git a/api/src/meshtastic.ts b/api/src/meshtastic.ts index c899343..360593f 100644 --- a/api/src/meshtastic.ts +++ b/api/src/meshtastic.ts @@ -33,6 +33,7 @@ import exitHook from 'exit-hook' import * as geolib from 'geolib' import axios from 'axios' import { State } from './lib/state' +import { nodeHistoryMap } from './nodeHistoryStore' let routeCache: State> @@ -350,8 +351,22 @@ export async function connect(address?: string) { let packet: MeshPacket if (id && data.latitudeI) packet = packets.upsert({ id, data }) if (e.from && data.latitudeI) { - let node = nodes.upsert({ num: e.from, position: data }) - if (packet?.viaMqtt === false) sendToMeshMap({ num: e.from, position: data }, node, packet) + let nodeNum = Number(e.from) + const record = { + latitudeI: data.latitudeI, + longitudeI: data.longitudeI, + timestampMs: Date.now() + } + if (!nodeHistoryMap.has(nodeNum)) { + nodeHistoryMap.set(nodeNum, []) + } + nodeHistoryMap.get(nodeNum)!.push(record) + // Optionally limit history size: + // const arr = nodeHistoryMap.get(nodeNum)! + // if (arr.length > 1000) arr.shift() + console.log(`Pushed history for node ${nodeNum}:`, record) + let node = nodes.upsert({ num: nodeNum, position: data }) + if (packet?.viaMqtt === false) sendToMeshMap({ num: nodeNum, position: data }, node, packet) } }) diff --git a/api/src/nodeHistoryStore.ts b/api/src/nodeHistoryStore.ts new file mode 100644 index 0000000..96c6a06 --- /dev/null +++ b/api/src/nodeHistoryStore.ts @@ -0,0 +1,7 @@ +export const nodeHistoryMap = new Map>(); + +// Example: Add a fake history for node 123 +nodeHistoryMap.set(123, [ + { latitudeI: 404123456, longitudeI: -747654321, timestampMs: 1620000000000 }, + { latitudeI: 404223456, longitudeI: -747654123, timestampMs: 1620000050000 }, +]); \ No newline at end of file diff --git a/api/tsconfig.json b/api/tsconfig.json index f20531b..0c78a60 100644 --- a/api/tsconfig.json +++ b/api/tsconfig.json @@ -97,5 +97,9 @@ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ - } + }, + "exclude": [ + "node_modules", + "webbluetooth/build" + ] } \ No newline at end of file diff --git a/build.mjs b/build.mjs index b53d0fd..48ebb7b 100755 --- a/build.mjs +++ b/build.mjs @@ -1,6 +1,5 @@ #!/usr/bin/env node import { spawn } from 'child_process' -import { styleText } from 'util'; import './api/node_modules/dotenv/config.js' let runCmd = (commandString) => new Promise((resolve, reject) => { @@ -13,14 +12,16 @@ let runCmd = (commandString) => new Promise((resolve, reject) => { let platform = process.platform.replace(/32$/, '').replace('darwin', 'mac') -console.log(styleText(['magenta', 'bold'], 'Building UI')) +console.log('🏗 Building UI…') process.chdir('ui') await runCmd('npm run build') -console.log(styleText(['magenta', 'bold'], 'Building API')) +console.log('🏗 Building API…') process.chdir('../api') await runCmd('npm run build') -console.log(styleText(['magenta', 'bold'], 'Building Electron')) +console.log('🏗 Building Electron…') process.chdir('../electron') -await runCmd(`npm run build:${platform} --c.extraMetadata.version=2.0.0`) \ No newline at end of file +await runCmd(`npm run build:${platform} --c.extraMetadata.version=2.0.0`) + +console.log('✅ Build complete!') diff --git a/electron/package.json b/electron/package.json index aec67bb..c21fbb0 100644 --- a/electron/package.json +++ b/electron/package.json @@ -39,5 +39,14 @@ "prettier": "^3.3.3", "typescript": "^5.6.3", "vite": "^5.4.9" + }, + "build": { + "extraResources": [ + { + "from": "../api/dist", + "to": "resources/api", + "filter": ["**/*"] + } + ] } } diff --git a/electron/src/main/index.ts b/electron/src/main/index.ts index 29b497b..1c1d32c 100644 --- a/electron/src/main/index.ts +++ b/electron/src/main/index.ts @@ -57,8 +57,17 @@ function createWindow(): void { // Some APIs can only be used after this event occurs. app.whenReady().then(async () => { console.log(`DIRNAME`, __dirname) - let apiPath = join(__dirname, '../../resources/api/index.cjs').replace('app.asar', 'app.asar.unpacked') + // Always resolve API_PATH from process.resourcesPath for both dev and packaged + const apiPath = join(process.resourcesPath, 'api', 'index.cjs') console.log(`API_PATH`, apiPath) + createWindow() + + // Only run auto-update check if we actually have an update manifest + try { + await autoUpdater.checkForUpdatesAndNotify() + } catch (e) { + console.warn('AutoUpdater: no update manifest found, skipping. ', e) + } apiProcess = utilityProcess.fork(apiPath, process.argv, { stdio: 'pipe' }) apiProcess.stdout?.on('data', (e) => process.stdout.write(e)) diff --git a/ui/src/Map.svelte b/ui/src/Map.svelte index c9e303c..859b624 100644 --- a/ui/src/Map.svelte +++ b/ui/src/Map.svelte @@ -27,8 +27,11 @@ import { getCoordinates, getNodeById, getNodeName, getNodeNameById, setPosition } from './lib/util' import { showConfigModal, showPage } from './SettingsModal.svelte' import { newsVisible } from './News.svelte' + import { fromLonLat } from 'ol/proj' + import { getNodeHistory, type HistoryRecord } from './stores/nodes'; + import { selectedHistoryNode, showHistoryPanel } from './stores/ui'; - export let ol: OpenLayersMap = undefined + export let ol: any; // or use the correct type if you have one $: nodesWithCoords = $filteredNodes.filter((n) => !(n.position?.latitudeI == undefined || n.position?.latitudeI == 0) || n.approximatePosition) @@ -68,6 +71,103 @@ } let modalPage = 'Settings' + + let trailArray: { coords: [number, number]; ts: number }[] = []; + let pendingTrail = false; + let timeWindowMs = 6 * 3600 * 1000; // default 6 hours + let rangeStart: number | null = null; + let rangeEnd: number | null = null; + + function pruneOldPoints() { + const cutoff = Date.now() - timeWindowMs; + console.log('→ Pruning old points; cutoff =', new Date(cutoff).toLocaleString()); + console.log(' before prune:', trailArray.length); + trailArray = trailArray.filter(p => p.ts >= cutoff); + console.log(' after prune:', trailArray.length); + } + + function scheduleTrailUpdate() { + if (pendingTrail) return; + pendingTrail = true; + + requestAnimationFrame(() => { + const coords = trailArray.map(p => p.coords); + console.log('→ scheduleTrailUpdate called, trailArray length =', trailArray.length); + console.log(' coords:', coords); + ol?.plotTrail(coords); + // also drop timestamped markers along the trail + ol?.plotTrailMarkers(trailArray); + pendingTrail = false; + }); + } + + $: if (ol) { + ol.plotTrail([]) + ol.plotTrailMarkers([]) + // ...existing plotData() or other init calls... + } + + $: { + // Whenever the selected node or its coords change… + const selectedNode = nodesWithCoords.find(n => n.num === $myNodeNum) + if (selectedNode?.position?.latitudeI && selectedNode?.position?.longitudeI) { + const lon = selectedNode.position.longitudeI / 1e7 + const lat = selectedNode.position.latitudeI / 1e7 + + // Only push if it’s truly new + const last = trailArray[trailArray.length - 1] + if (!last || last.coords[0] !== lon || last.coords[1] !== lat) { + trailArray.push({ coords: [lon, lat], ts: Date.now() }) + pruneOldPoints() + scheduleTrailUpdate() + } + } + } + + async function onTimestampClick(entry: any) { + if (rangeStart === null || (rangeStart !== null && rangeEnd !== null)) { + rangeStart = entry.timestampMs; + rangeEnd = null; + trailArray = []; + scheduleTrailUpdate(); + } else if (rangeEnd === null) { + rangeEnd = entry.timestampMs; + applyHistoryRange(); + } + } + + async function applyHistoryRange() { + if (!$selectedHistoryNode || rangeStart === null || rangeEnd === null) return; + const historyRecords: HistoryRecord[] = await getNodeHistory($selectedHistoryNode); + const start = Math.min(rangeStart, rangeEnd); + const end = Math.max(rangeStart, rangeEnd); + const points = historyRecords + .map(r => ({ + coords: [r.longitudeI / 1e7, r.latitudeI / 1e7] as [number, number], + ts: r.timestampMs + })) + .filter(p => p.ts >= start && p.ts <= end) + .sort((a, b) => a.ts - b.ts); + const uniquePoints = points.filter((p, i, arr) => { + if (i === 0) return true; + const [prevLon, prevLat] = arr[i - 1].coords; + return p.coords[0] !== prevLon || p.coords[1] !== prevLat; + }); + trailArray = uniquePoints; + scheduleTrailUpdate(); + } + + function resetHistoryRange() { + rangeStart = rangeEnd = null; + trailArray = []; + scheduleTrailUpdate(); + } + + let historyList: any[] = []; + + $: if ($selectedHistoryNode && $showHistoryPanel) { + getNodeHistory($selectedHistoryNode).then(list => historyList = list); + } @@ -101,11 +201,34 @@ } }} onDarkModeToggle={plotData} - > + /> {#if $setPositionMode}
Click on a new position for {getNodeNameById($myNodeNum)}
{/if} + + {#if $showHistoryPanel} +
+

Node History for #{$selectedHistoryNode}

+

Select start and end timestamps to draw a trail.

+ {#if historyList.length === 0} +

No history available for this node.

+ {:else} + {#each historyList as entry (entry.timestampMs)} + + {/each} + {/if} +
+ + +
+
+ {/if}
diff --git a/ui/src/Nodes.svelte b/ui/src/Nodes.svelte index d6583c9..98e8fd1 100644 --- a/ui/src/Nodes.svelte +++ b/ui/src/Nodes.svelte @@ -22,6 +22,7 @@ import { getSvgUri, setPositionMode } from './Map.svelte' import ChannelUtilization from './lib/ChannelUtilization.svelte' import ObservedRF from './lib/ObservedRF.svelte' + import { selectedHistoryNode, showHistoryPanel } from './stores/ui'; export let includeMqtt = (localStorage.getItem('includeMqtt') ?? 'true') == 'true' let selectedNode: NodeInfo @@ -269,7 +270,7 @@ {/if}
@@ -288,6 +289,17 @@ title="Traceroute {node.hopsAway == 0 ? 'Direct' : ''}{node?.trace ? [$myNodeNum, ...node?.trace?.route, node?.num].map((id) => getNodeNameById(id)).join(' -> ') : ''}" on:click={() => axios.post('/traceRoute', { destination: node.num })}>↯ + + + {:else if $hasAccess}