From 256886f2e80c529a012ba5fa9b0b792af6c5c7fb Mon Sep 17 00:00:00 2001 From: ajvpot <553597+ajvpot@users.noreply.github.com> Date: Tue, 9 Sep 2025 04:01:19 +0200 Subject: [PATCH] Node info page improvements, search, neighbors --- package-lock.json | 283 ++++++++++- package.json | 3 + public/favicon.svg | 28 ++ src/app/api-docs/page.tsx | 97 +++- .../node/[publicKey]/neighbors/route.ts | 55 +++ .../api/meshcore/node/[publicKey]/route.ts | 33 +- src/app/api/meshcore/search/route.ts | 82 ++++ src/app/favicon.ico | Bin 25931 -> 0 bytes src/app/layout.tsx | 15 +- src/app/meshcore/node/[publicKey]/page.tsx | 454 +++++++++++++----- src/app/search/page.tsx | 164 +++++++ src/app/stats/page.tsx | 39 ++ src/components/AdvertDetails.tsx | 168 +++++++ src/components/ChatMessageItem.tsx | 262 +--------- src/components/ConfigContext.tsx | 2 +- src/components/ContactQRCode.tsx | 47 ++ src/components/Header.tsx | 14 +- src/components/MapView.tsx | 170 ++++++- src/components/PathDisplay.tsx | 29 ++ src/components/PathVisualization.tsx | 284 +++++++++++ src/components/QueryProvider.tsx | 23 + src/components/SearchInput.tsx | 84 ++++ src/components/SearchResults.tsx | 160 ++++++ src/hooks/useMeshcoreSearch.ts | 79 +++ src/hooks/useNeighbors.ts | 46 ++ src/hooks/useSearchQuery.ts | 124 +++++ src/lib/clickhouse/actions.ts | 322 +++++++++++-- 27 files changed, 2629 insertions(+), 438 deletions(-) create mode 100644 public/favicon.svg create mode 100644 src/app/api/meshcore/node/[publicKey]/neighbors/route.ts create mode 100644 src/app/api/meshcore/search/route.ts delete mode 100644 src/app/favicon.ico create mode 100644 src/app/search/page.tsx create mode 100644 src/components/AdvertDetails.tsx create mode 100644 src/components/ContactQRCode.tsx create mode 100644 src/components/PathDisplay.tsx create mode 100644 src/components/PathVisualization.tsx create mode 100644 src/components/QueryProvider.tsx create mode 100644 src/components/SearchInput.tsx create mode 100644 src/components/SearchResults.tsx create mode 100644 src/hooks/useMeshcoreSearch.ts create mode 100644 src/hooks/useNeighbors.ts create mode 100644 src/hooks/useSearchQuery.ts diff --git a/package-lock.json b/package-lock.json index a250afe..c88bf5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,9 @@ "@clickhouse/client": "^1.11.2", "@headlessui/react": "^2.2.4", "@heroicons/react": "^2.2.0", + "@tanstack/react-query": "^5.87.1", "@types/leaflet": "^1.9.19", + "@types/qrcode": "^1.5.5", "aes-js": "^3.1.2", "class-variance-authority": "^0.7.1", "clickhouse": "^2.6.0", @@ -22,6 +24,7 @@ "moment": "^2.30.1", "next": "15.3.4", "next-themes": "^0.4.6", + "qrcode": "^1.5.4", "react": "^19.0.0", "react-d3-tree": "^3.6.6", "react-dom": "^19.1.0", @@ -1415,6 +1418,30 @@ "tailwindcss": "4.1.11" } }, + "node_modules/@tanstack/query-core": { + "version": "5.87.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.87.1.tgz", + "integrity": "sha512-HOFHVvhOCprrWvtccSzc7+RNqpnLlZ5R6lTmngb8aq7b4rc2/jDT0w+vLdQ4lD9bNtQ+/A4GsFXy030Gk4ollA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.87.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.87.1.tgz", + "integrity": "sha512-YKauf8jfMowgAqcxj96AHs+Ux3m3bWT1oSVKamaRPXSnW2HqSznnTCEkAVqctF1e/W9R/mPcyzzINIgpOH94qg==", + "dependencies": { + "@tanstack/query-core": "5.87.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tanstack/react-virtual": { "version": "3.13.12", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz", @@ -1496,11 +1523,18 @@ "version": "20.19.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.4.tgz", "integrity": "sha512-OP+We5WV8Xnbuvw0zC2m4qfB/BJvjyCwtNjhHdJxV1639SGSKrLmJkc3fMnp2Qy8nJyHp8RO6umxELN/dS1/EA==", - "dev": true, "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz", + "integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.1.8", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", @@ -2082,11 +2116,18 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -2449,6 +2490,14 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001726", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz", @@ -2534,6 +2583,16 @@ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/clone": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", @@ -2567,7 +2626,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "devOptional": true, "dependencies": { "color-name": "~1.1.4" }, @@ -2578,8 +2636,7 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "devOptional": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/color-string": { "version": "1.9.1", @@ -2832,6 +2889,14 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2897,6 +2962,11 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -3743,6 +3813,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -4217,6 +4295,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", @@ -5362,6 +5448,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "engines": { + "node": ">=6" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -5378,7 +5472,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "engines": { "node": ">=8" } @@ -5420,6 +5513,14 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -5495,6 +5596,22 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qs": { "version": "6.5.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", @@ -5675,6 +5792,19 @@ "node": ">= 4" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -5839,6 +5969,11 @@ "node": ">=10" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -6092,6 +6227,24 @@ "node": ">=10.0.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -6199,6 +6352,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -6567,8 +6731,7 @@ "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" }, "node_modules/unrs-resolver": { "version": "1.10.1", @@ -6729,6 +6892,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==" + }, "node_modules/which-typed-array": { "version": "1.1.19", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", @@ -6759,6 +6927,24 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -6768,6 +6954,87 @@ "node": ">=18" } }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 762391e..8f31ce3 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "@clickhouse/client": "^1.11.2", "@headlessui/react": "^2.2.4", "@heroicons/react": "^2.2.0", + "@tanstack/react-query": "^5.87.1", "@types/leaflet": "^1.9.19", + "@types/qrcode": "^1.5.5", "aes-js": "^3.1.2", "class-variance-authority": "^0.7.1", "clickhouse": "^2.6.0", @@ -23,6 +25,7 @@ "moment": "^2.30.1", "next": "15.3.4", "next-themes": "^0.4.6", + "qrcode": "^1.5.4", "react": "^19.0.0", "react-d3-tree": "^3.6.6", "react-dom": "^19.1.0", diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..47ed759 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/api-docs/page.tsx b/src/app/api-docs/page.tsx index eb7b850..a57b82e 100644 --- a/src/app/api-docs/page.tsx +++ b/src/app/api-docs/page.tsx @@ -209,7 +209,7 @@ export default function ApiDocsPage() {

Meshcore Node API

GET /api/meshcore/node/{`{publicKey}`}

-

Retrieve detailed information about a specific meshcore node including recent adverts and location history.

+

Retrieve detailed information about a specific meshcore node including recent adverts, location history, and MQTT uplink status.

Path Parameters

@@ -255,6 +255,63 @@ export default function ApiDocsPage() {
+

Response Fields

+
+
Node Object
+ +
+ +
+
Recent Adverts Array
+ +
+ +
+
Location History Array
+ +
+ +
+
MQTT Object
+ +
+ +
+
MQTT Topics Array
+ +
+

Response Format

@@ -269,13 +326,18 @@ export default function ApiDocsPage() {
     "is_chat_node": 1,
     "is_room_server": 0,
     "has_name": 1,
-    "last_seen": "2025-09-07T00:59:18"
+    "last_seen": "2025-09-07T00:59:18",
+    "first_seen": "2025-09-01T10:00:00"
   },
   "recentAdverts": [
     {
-      "mesh_timestamp": "2025-09-07T00:59:18",
-      "path": "7ffb7e",
-      "path_len": 3,
+      "adv_timestamp": "2025-09-07T00:59:18",
+      "origin_path_pubkey_tuples": [
+        ["origin_node", "7ffb7e", "origin_pubkey_hex"]
+      ],
+      "advert_count": 1,
+      "earliest_timestamp": "2025-09-07T00:59:18",
+      "latest_timestamp": "2025-09-07T00:59:18",
       "latitude": 47.54969,
       "longitude": -122.28085999999999,
       "is_repeater": 1,
@@ -288,11 +350,27 @@ export default function ApiDocsPage() {
     {
       "mesh_timestamp": "2025-09-07T00:59:18",
       "latitude": 47.54969,
-      "longitude": -122.28085999999999,
-      "path": "7ffb7e",
-      "path_len": 3
+      "longitude": -122.28085999999999
     }
-  ]
+  ],
+  "mqtt": {
+    "is_uplinked": true,
+    "has_packets": true,
+    "topics": [
+      {
+        "topic": "meshcore",
+        "broker": "tcp://mqtt.davekeogh.com:1883",
+        "last_packet_time": "2025-09-07T00:59:18",
+        "is_recent": 1
+      },
+      {
+        "topic": "meshcore/pdx",
+        "broker": "tcp://mqtt.davekeogh.com:1883",
+        "last_packet_time": "2025-09-06T15:30:45",
+        "is_recent": 1
+      }
+    ]
+  }
 }`}
                     
@@ -531,6 +609,7 @@ export default function ApiDocsPage() { {`GET /api/meshcore/node/82D396A8754609E302A2A3FDB9210A1C67C7081606C16A89F77AD75C16E1DA1A?limit=100`}
+

This will return detailed information about the specified meshcore node including recent adverts, location history, and MQTT uplink status.

diff --git a/src/app/api/meshcore/node/[publicKey]/neighbors/route.ts b/src/app/api/meshcore/node/[publicKey]/neighbors/route.ts new file mode 100644 index 0000000..b1afa34 --- /dev/null +++ b/src/app/api/meshcore/node/[publicKey]/neighbors/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server"; +import { getMeshcoreNodeNeighbors } from "@/lib/clickhouse/actions"; + +export async function GET( + req: Request, + { params }: { params: Promise<{ publicKey: string }> } +) { + try { + const { publicKey } = await params; + const { searchParams } = new URL(req.url); + const lastSeen = searchParams.get("lastSeen"); + + if (!publicKey) { + return NextResponse.json({ + error: "Public key is required", + code: "MISSING_PUBLIC_KEY" + }, { status: 400 }); + } + + // Validate public key format (basic validation) + if (publicKey.length < 10) { + return NextResponse.json({ + error: "Invalid public key format", + code: "INVALID_PUBLIC_KEY" + }, { status: 400 }); + } + + const neighbors = await getMeshcoreNodeNeighbors(publicKey, lastSeen); + + // Check if the parent node exists by trying to get basic node info + // This is a lightweight check to ensure the node exists before returning neighbors + if (!neighbors || neighbors.length === 0) { + // We could add a check here to verify the parent node exists + // For now, we'll return an empty array which is valid for nodes with no neighbors + return NextResponse.json([]); + } + + return NextResponse.json(neighbors); + } catch (error) { + console.error("Error fetching meshcore node neighbors:", error); + + // Check if it's a ClickHouse connection error + if (error instanceof Error && error.message.includes('ClickHouse')) { + return NextResponse.json({ + error: "Database temporarily unavailable", + code: "DATABASE_ERROR" + }, { status: 503 }); + } + + return NextResponse.json({ + error: "Failed to fetch neighbors", + code: "INTERNAL_ERROR" + }, { status: 500 }); + } +} diff --git a/src/app/api/meshcore/node/[publicKey]/route.ts b/src/app/api/meshcore/node/[publicKey]/route.ts index 8146f1e..4904478 100644 --- a/src/app/api/meshcore/node/[publicKey]/route.ts +++ b/src/app/api/meshcore/node/[publicKey]/route.ts @@ -11,18 +11,45 @@ export async function GET( const limit = parseInt(searchParams.get("limit") || "50", 10); if (!publicKey) { - return NextResponse.json({ error: "Public key is required" }, { status: 400 }); + return NextResponse.json({ + error: "Public key is required", + code: "MISSING_PUBLIC_KEY" + }, { status: 400 }); + } + + // Validate public key format (basic validation) + if (publicKey.length < 10) { + return NextResponse.json({ + error: "Invalid public key format", + code: "INVALID_PUBLIC_KEY" + }, { status: 400 }); } const nodeInfo = await getMeshcoreNodeInfo(publicKey, limit); if (!nodeInfo) { - return NextResponse.json({ error: "Node not found" }, { status: 404 }); + return NextResponse.json({ + error: "Node not found", + code: "NODE_NOT_FOUND", + publicKey: publicKey + }, { status: 404 }); } return NextResponse.json(nodeInfo); } catch (error) { console.error("Error fetching meshcore node info:", error); - return NextResponse.json({ error: "Failed to fetch node info" }, { status: 500 }); + + // Check if it's a ClickHouse connection error + if (error instanceof Error && error.message.includes('ClickHouse')) { + return NextResponse.json({ + error: "Database temporarily unavailable", + code: "DATABASE_ERROR" + }, { status: 503 }); + } + + return NextResponse.json({ + error: "Failed to fetch node info", + code: "INTERNAL_ERROR" + }, { status: 500 }); } } diff --git a/src/app/api/meshcore/search/route.ts b/src/app/api/meshcore/search/route.ts new file mode 100644 index 0000000..b07841d --- /dev/null +++ b/src/app/api/meshcore/search/route.ts @@ -0,0 +1,82 @@ +import { NextResponse } from "next/server"; +import { searchMeshcoreNodes } from "@/lib/clickhouse/actions"; + +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url); + const query = searchParams.get("q"); + const region = searchParams.get("region"); + const lastSeen = searchParams.get("lastSeen"); + const limit = parseInt(searchParams.get("limit") || "50", 10); + + // Validate limit + if (limit < 1 || limit > 200) { + return NextResponse.json({ + error: "Limit must be between 1 and 200", + code: "INVALID_LIMIT" + }, { status: 400 }); + } + + // If no query provided, return empty results + if (!query || query.trim().length === 0) { + return NextResponse.json({ + results: [], + total: 0, + query: query || "", + region: region || null + }); + } + + // Validate query length + if (query.length > 100) { + return NextResponse.json({ + error: "Query too long (max 100 characters)", + code: "QUERY_TOO_LONG" + }, { status: 400 }); + } + + // Validate lastSeen parameter + let lastSeenValue: string | null = null; + if (lastSeen !== null) { + const lastSeenNum = parseInt(lastSeen, 10); + if (isNaN(lastSeenNum) || lastSeenNum < 0) { + return NextResponse.json({ + error: "lastSeen must be a positive number (seconds)", + code: "INVALID_LAST_SEEN" + }, { status: 400 }); + } + lastSeenValue = lastSeen; + } + + const results = await searchMeshcoreNodes({ + query: query.trim(), + region: region || undefined, + lastSeen: lastSeenValue, + limit + }); + + return NextResponse.json({ + results, + total: results.length, + query: query.trim(), + region: region || null, + lastSeen: lastSeenValue, + limit + }); + } catch (error) { + console.error("Error searching meshcore nodes:", error); + + // Check if it's a ClickHouse connection error + if (error instanceof Error && error.message.includes('ClickHouse')) { + return NextResponse.json({ + error: "Database temporarily unavailable", + code: "DATABASE_ERROR" + }, { status: 503 }); + } + + return NextResponse.json({ + error: "Failed to search nodes", + code: "INTERNAL_ERROR" + }, { status: 500 }); + } +} diff --git a/src/app/favicon.ico b/src/app/favicon.ico deleted file mode 100644 index 718d6fea4835ec2d246af9800eddb7ffb276240c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25931 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 LrJugUA?W`A8`#=m diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d0a6443..a7d6006 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import Header from "../components/Header"; import { ConfigProvider } from "../components/ConfigContext"; +import { QueryProvider } from "../components/QueryProvider"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -17,6 +18,10 @@ const geistMono = Geist_Mono({ export const metadata: Metadata = { title: "MeshExplorer", description: "A real-time map, chat client, and packet analysis tool for mesh networks using MeshCore and Meshtastic.", + icons: { + icon: { url: "/favicon.svg", type: "image/svg+xml" }, + apple: { url: "/favicon.svg", type: "image/svg+xml" } + }, }; export const viewport: Viewport = { @@ -38,10 +43,12 @@ export default function RootLayout({ style={{ '--header-height': '64px' } as React.CSSProperties} >
- -
-
{children}
- + + +
+
{children}
+ +
diff --git a/src/app/meshcore/node/[publicKey]/page.tsx b/src/app/meshcore/node/[publicKey]/page.tsx index 62b5787..dbd7787 100644 --- a/src/app/meshcore/node/[publicKey]/page.tsx +++ b/src/app/meshcore/node/[publicKey]/page.tsx @@ -2,9 +2,15 @@ import { useEffect, useState } from "react"; import { useParams } from "next/navigation"; +import Link from "next/link"; import moment from "moment"; import { formatPublicKey } from "@/lib/meshcore"; import { getNameIconLabel } from "@/lib/meshcore-map-nodeutils"; +import PathDisplay from "@/components/PathDisplay"; +import AdvertDetails from "@/components/AdvertDetails"; +import ContactQRCode from "@/components/ContactQRCode"; +import { useConfig, LAST_SEEN_OPTIONS } from "@/components/ConfigContext"; +import { useNeighbors, type Neighbor } from "@/hooks/useNeighbors"; interface NodeInfo { public_key: string; @@ -16,39 +22,42 @@ interface NodeInfo { is_chat_node: number; is_room_server: number; has_name: number; - last_seen: string; } interface Advert { - mesh_timestamp: string; - path: string; - path_len: number; + group_id: number; + origin_path_pubkey_tuples: Array<[string, string, string]>; // Array of [origin, path, origin_pubkey] tuples + advert_count: number; + earliest_timestamp: string; + latest_timestamp: string; latitude: number | null; longitude: number | null; is_repeater: number; is_chat_node: number; is_room_server: number; has_location: number; - origin_pubkey: string; - full_path: string; } interface LocationHistory { mesh_timestamp: string; latitude: number; longitude: number; - path: string; - path_len: number; - origin_pubkey: string; - full_path: string; +} + +interface MqttTopic { + topic: string; + broker: string; + last_packet_time: string; + is_recent: boolean; } interface MqttInfo { is_uplinked: boolean; - last_uplink_time: string | null; has_packets: boolean; + topics: MqttTopic[]; } + interface NodeData { node: NodeInfo; recentAdverts: Advert[]; @@ -56,34 +65,77 @@ interface NodeData { mqtt: MqttInfo; } +// Function to determine node type based on capabilities +function getNodeType(node: NodeInfo): number { + if (node.is_chat_node) return 1; // companion + if (node.is_repeater) return 2; // repeater + if (node.is_room_server) return 3; // room + return 4; // sensor (default for standard nodes) +} + export default function MeshcoreNodePage() { const params = useParams(); const publicKey = params.publicKey as string; + const { config } = useConfig(); const [nodeData, setNodeData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [errorCode, setErrorCode] = useState(null); + // Use TanStack Query for neighbors data - only fetch if node is uplinked + const { + data: neighbors = [], + isLoading: neighborsLoading + } = useNeighbors({ + nodeId: publicKey, + lastSeen: config.lastSeen, + enabled: !!nodeData?.mqtt?.is_uplinked + }); + + // Fetch node data only when publicKey changes useEffect(() => { if (!publicKey) return; const fetchNodeData = async () => { try { setLoading(true); - const response = await fetch(`/api/meshcore/node/${publicKey}`); + setError(null); + setErrorCode(null); - if (response.status === 404) { - setError("Node not found"); + // Fetch node data + const nodeResponse = await fetch(`/api/meshcore/node/${publicKey}`); + + if (nodeResponse.status === 404) { + const errorData = await nodeResponse.json().catch(() => ({})); + setError(errorData.error || "Node not found"); + setErrorCode(errorData.code || "NODE_NOT_FOUND"); return; } - if (!response.ok) { - throw new Error("Failed to fetch node data"); + if (nodeResponse.status === 400) { + const errorData = await nodeResponse.json().catch(() => ({})); + setError(errorData.error || "Invalid request"); + setErrorCode(errorData.code || "BAD_REQUEST"); + return; } - const data = await response.json(); - setNodeData(data); + if (nodeResponse.status === 503) { + const errorData = await nodeResponse.json().catch(() => ({})); + setError(errorData.error || "Service temporarily unavailable"); + setErrorCode(errorData.code || "SERVICE_UNAVAILABLE"); + return; + } + + if (!nodeResponse.ok) { + const errorData = await nodeResponse.json().catch(() => ({})); + throw new Error(errorData.error || "Failed to fetch node data"); + } + + const nodeData = await nodeResponse.json(); + setNodeData(nodeData); } catch (err) { setError(err instanceof Error ? err.message : "An error occurred"); + setErrorCode("NETWORK_ERROR"); } finally { setLoading(false); } @@ -92,6 +144,7 @@ export default function MeshcoreNodePage() { fetchNodeData(); }, [publicKey]); + if (loading) { return (
@@ -104,12 +157,119 @@ export default function MeshcoreNodePage() { } if (error) { + const getErrorIcon = (code: string | null) => { + switch (code) { + case "NODE_NOT_FOUND": + return "🔍"; + case "INVALID_PUBLIC_KEY": + case "MISSING_PUBLIC_KEY": + return "❌"; + case "SERVICE_UNAVAILABLE": + case "DATABASE_ERROR": + return "🔧"; + case "NETWORK_ERROR": + return "🌐"; + default: + return "⚠️"; + } + }; + + const getErrorTitle = (code: string | null) => { + switch (code) { + case "NODE_NOT_FOUND": + return "Node Not Found"; + case "INVALID_PUBLIC_KEY": + case "MISSING_PUBLIC_KEY": + return "Invalid Public Key"; + case "SERVICE_UNAVAILABLE": + case "DATABASE_ERROR": + return "Service Unavailable"; + case "NETWORK_ERROR": + return "Connection Error"; + default: + return "Error"; + } + }; + + const getErrorDescription = (code: string | null, publicKey: string) => { + switch (code) { + case "NODE_NOT_FOUND": + return ( +
+

+ The node with public key {formatPublicKey(publicKey)} was not found in the database. +

+

+ This could mean the node has never been seen on the mesh network, or the public key is incorrect. +

+
+ ); + case "INVALID_PUBLIC_KEY": + case "MISSING_PUBLIC_KEY": + return ( +
+

+ The provided public key is invalid or missing. +

+

+ Please check the URL and try again with a valid public key. +

+
+ ); + case "SERVICE_UNAVAILABLE": + case "DATABASE_ERROR": + return ( +
+

+ The database is temporarily unavailable. +

+

+ Please try again in a few moments. +

+
+ ); + case "NETWORK_ERROR": + return ( +
+

+ Unable to connect to the server. +

+

+ Please check your internet connection and try again. +

+
+ ); + default: + return

{error}

; + } + }; + return (
-
-
⚠️
-

Error

-

{error}

+
+
{getErrorIcon(errorCode)}
+

+ {getErrorTitle(errorCode)} +

+ {getErrorDescription(errorCode, publicKey)} + +
+ + +
+ + ← Back to Mesh Explorer + +
+
); @@ -119,9 +279,7 @@ export default function MeshcoreNodePage() { return (
-
-

No Data

-

No node data available

+
No data available
); @@ -148,28 +306,36 @@ export default function MeshcoreNodePage() {

{formatPublicKey(node.public_key)}

-
-
-
+
{node.is_repeater && ( Repeater - )} + ) || null} {node.is_chat_node && ( - Chat Node + Companion - )} + ) || null} {node.is_room_server && ( Room Server - )} + ) || null} + {!node.is_repeater && !node.is_chat_node && !node.is_room_server && ( + + Unknown + + ) || null}
-

- Last seen: {moment.utc(node.last_seen).format('YYYY-MM-DD HH:mm:ss')} UTC -

+
+
+
@@ -182,18 +348,12 @@ export default function MeshcoreNodePage() {
-
+
Public Key
{node.public_key}
-
-
Node Name
-
- {node.has_name ? node.node_name : "Not set"} -
-
Current Location
@@ -206,20 +366,10 @@ export default function MeshcoreNodePage() { )}
-
-
Last Seen
-
- {moment.utc(node.last_seen).format('YYYY-MM-DD HH:mm:ss')} UTC -
- - {moment.utc(node.last_seen).local().fromNow()} - -
-
-
+
MQTT Uplink
-
+
- {mqtt.has_packets && mqtt.last_uplink_time && ( -
- Last packet: {moment.utc(mqtt.last_uplink_time).format('YYYY-MM-DD HH:mm:ss')} UTC -
- - {moment.utc(mqtt.last_uplink_time).local().fromNow()} - + + {mqtt.topics && mqtt.topics.length > 0 && ( +
+

Topics:

+
+ {mqtt.topics.map((topic, index) => ( +
+
+ {topic.topic} + ({topic.broker}) +
+
+
+ {moment.utc(topic.last_packet_time).format('MM-DD HH:mm')} +
+
+ {moment.utc(topic.last_packet_time).local().fromNow()} +
+
+
+ ))} +
- )} + ) || null}
@@ -248,63 +413,18 @@ export default function MeshcoreNodePage() {

Recent Adverts

-

Latest {recentAdverts.length} adverts with path information

+

Latest {recentAdverts.length} adverts

-
- - - - - - - - - - - - {recentAdverts.map((advert, index) => ( - - - - - - - - ))} - -
TimestampPathLengthLocationType
- {moment.utc(advert.mesh_timestamp).format('MM-DD HH:mm:ss')} - - {advert.full_path ? advert.full_path.match(/.{1,2}/g)?.join(' ') || advert.full_path : "-"} - - {advert.path_len} - - {advert.latitude && advert.longitude ? ( - - {advert.latitude.toFixed(4)}, {advert.longitude.toFixed(4)} - - ) : ( - - - )} - -
- {advert.is_repeater && ( - - R - - )} - {advert.is_chat_node && ( - - C - - )} - {advert.is_room_server && ( - - S - - )} -
-
+
+ {recentAdverts.length === 0 ? ( +
+ No adverts found +
+ ) : ( + recentAdverts.map((advert) => ( + + )) + )}
@@ -342,6 +462,104 @@ export default function MeshcoreNodePage() {
+ + {/* Neighbors Section - Only show if MQTT uplink is connected */} + {mqtt.is_uplinked && ( +
+
+

+ Neighbors ({neighborsLoading ? "..." : neighbors.length}) +

+

+ Nodes heard directly by this node + {config.lastSeen !== null && ( + + Last {(() => { + const option = LAST_SEEN_OPTIONS.find(opt => opt.value === config.lastSeen); + return option ? option.label : `${Math.floor(config.lastSeen / 3600)}h`; + })()} + + )} +

+
+
+ {neighborsLoading ? ( +
+
+ Loading neighbors... +
+ ) : neighbors.length === 0 ? ( +
+ No neighbors found +
+ ) : ( +
+ {neighbors.map((neighbor) => ( +
+
+
+

+ {neighbor.has_name ? getNameIconLabel(neighbor.node_name) : "Unknown Node"} +

+ {neighbor.has_name && ( +

+ {neighbor.node_name} +

+ )} +

+ {formatPublicKey(neighbor.public_key)} +

+
+ + View → + +
+ +
+ {neighbor.is_repeater && ( + + Repeater + + ) || null} + {neighbor.is_chat_node && ( + + Companion + + ) || null} + {neighbor.is_room_server && ( + + Room + + ) || null} +
+ +
+ {neighbor.has_location && neighbor.latitude && neighbor.longitude && ( +
+ Location: {neighbor.latitude.toFixed(4)}, {neighbor.longitude.toFixed(4)} +
+ ) || null} + {neighbor.directions && neighbor.directions.length > 0 && ( +
+ Direction: + {neighbor.directions.includes('incoming') && 📥} + {neighbor.directions.includes('outgoing') && 📤} + {neighbor.directions.includes('incoming') && neighbor.directions.includes('outgoing') && ( + ↔️ Bidirectional + )} +
+ )} +
+
+ ))} +
+ )} +
+
+ )} ); diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx new file mode 100644 index 0000000..9f4e2c2 --- /dev/null +++ b/src/app/search/page.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useConfig } from '@/components/ConfigContext'; +import { useSearchQuery } from '@/hooks/useSearchQuery'; +import { useMeshcoreSearch } from '@/hooks/useMeshcoreSearch'; +import SearchInput from '@/components/SearchInput'; +import SearchResults from '@/components/SearchResults'; +import RegionSelector from '@/components/RegionSelector'; +import { LAST_SEEN_OPTIONS } from '@/components/ConfigContext'; +import { useState, Suspense } from 'react'; +import { ChevronDownIcon } from '@heroicons/react/24/outline'; + +function SearchPageContent() { + const { config } = useConfig(); + const { query, setQuery, setLimit } = useSearchQuery(); + const [showFilters, setShowFilters] = useState(false); + + // Always use config values for region and lastSeen + const searchParams = { + query: query.q, + region: config.selectedRegion, + lastSeen: config.lastSeen, + limit: query.limit || 50, + }; + + const { data, isLoading, error } = useMeshcoreSearch({ + ...searchParams, + enabled: true, + }); + + const { setConfig } = useConfig(); + + const handleRegionChange = (region: string) => { + setConfig({ ...config, selectedRegion: region || undefined }); + }; + + const handleLastSeenChange = (lastSeen: number | null) => { + setConfig({ ...config, lastSeen }); + }; + + const handleLimitChange = (limit: number) => { + setLimit(limit); + }; + + return ( +
+
+ {/* Header */} +
+

+ Search MeshCore Nodes +

+

+ Find nodes by name or public key across the mesh network +

+
+ + {/* Search Input */} +
+ +
+ + {/* Filters */} +
+ + + {showFilters && ( +
+
+ {/* Region Filter */} +
+ + +
+ + {/* Last Seen Filter */} +
+ + +
+ + {/* Limit Filter */} +
+ + +
+
+
+ )} +
+ + {/* Search Results */} + +
+
+ ); +} + +export default function SearchPage() { + return ( + +
+
+

Loading search...

+
+ + }> + +
+ ); +} diff --git a/src/app/stats/page.tsx b/src/app/stats/page.tsx index 03c00c3..aaa0517 100644 --- a/src/app/stats/page.tsx +++ b/src/app/stats/page.tsx @@ -11,6 +11,7 @@ export default function StatsPage() { const [nodesOverTime, setNodesOverTime] = useState([]); const [popularChannels, setPopularChannels] = useState([]); const [repeaterPrefixes, setRepeaterPrefixes] = useState([]); + const [unusedPrefixes, setUnusedPrefixes] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { @@ -30,6 +31,20 @@ export default function StatsPage() { setNodesOverTime(nodesOverTimeRes.data ?? []); setPopularChannels(popularChannelsRes.data ?? []); setRepeaterPrefixes(repeaterPrefixesRes.data ?? []); + + // Generate all possible 2-character hex prefixes (01-FE, excluding 00 and FF) + const allPrefixes = []; + for (let i = 1; i < 255; i++) { + allPrefixes.push(i.toString(16).padStart(2, '0').toUpperCase()); + } + + // Get used prefixes from the API response + const usedPrefixes = new Set((repeaterPrefixesRes.data ?? []).map((row: any) => row.prefix)); + + // Find unused prefixes + const unused = allPrefixes.filter(prefix => !usedPrefixes.has(prefix)); + setUnusedPrefixes(unused); + setLoading(false); } fetchStats(); @@ -146,6 +161,30 @@ export default function StatsPage() { + +
+

Unused Repeater Prefixes

+

+ Public key prefixes that are not currently used by any repeater nodes. Click to generate a key. +

+
+ {unusedPrefixes.map((prefix) => ( + + {prefix} + + ))} +
+
+ Total unused prefixes: {unusedPrefixes.length} out of 254 possible (excluding 00 and FF) +
+
)} diff --git a/src/components/AdvertDetails.tsx b/src/components/AdvertDetails.tsx new file mode 100644 index 0000000..8e9705e --- /dev/null +++ b/src/components/AdvertDetails.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { useState } from "react"; +import moment from "moment"; +import PathVisualization, { PathData } from "./PathVisualization"; + +interface AdvertDetailsProps { + advert: { + group_id: number; + origin_path_pubkey_tuples: Array<[string, string, string]>; + advert_count: number; + earliest_timestamp: string; + latest_timestamp: string; + latitude: number | null; + longitude: number | null; + is_repeater: number; + is_chat_node: number; + is_room_server: number; + has_location: number; + }; + initiatingNodeKey?: string; +} + +export default function AdvertDetails({ advert, initiatingNodeKey }: AdvertDetailsProps) { + const [isExpanded, setIsExpanded] = useState(false); + + const timeRange = advert.earliest_timestamp !== advert.latest_timestamp + ? `to ${moment.utc(advert.latest_timestamp).format('HH:mm:ss')}` : ''; + + return ( +
+ {/* Header - Always visible */} +
setIsExpanded(!isExpanded)} + > +
+
+
+ {moment.utc(advert.earliest_timestamp).format('MM-DD HH:mm:ss')} +
+
+ {timeRange} +
+
+ Heard {advert.advert_count} time{advert.advert_count !== 1 ? 's' : ''} +
+
+
+
+ {advert.is_repeater && ( + + R + + ) || null} + {advert.is_chat_node && ( + + C + + ) || null} + {advert.is_room_server && ( + + S + + ) || null} +
+ + + +
+
+
+ + {/* Expandable content */} + {isExpanded && ( +
+
+ {/* Path details */} +
+ ({ + origin: origin || origin_pubkey.substring(0, 8), // Use origin name if available, fallback to pubkey + pubkey: origin_pubkey, + path: path + }))} + className="text-sm" + initiatingNodeKey={initiatingNodeKey} + /> +
+ + {/* Location details */} + {advert.has_location && advert.latitude && advert.longitude && ( +
+

+ Location +

+
+ {advert.latitude.toFixed(6)}, {advert.longitude.toFixed(6)} +
+
+ )} + + {/* Timestamp details */} +
+

+ Timestamps +

+
+
+ Earliest: {moment.utc(advert.earliest_timestamp).format('YYYY-MM-DD HH:mm:ss')} UTC +
+
+ Latest: {moment.utc(advert.latest_timestamp).format('YYYY-MM-DD HH:mm:ss')} UTC +
+ {advert.earliest_timestamp !== advert.latest_timestamp && ( +
+ Duration: {moment.utc(advert.latest_timestamp).diff(moment.utc(advert.earliest_timestamp), 'seconds')} seconds +
+ )} +
+
+ + {/* Node capabilities */} +
+

+ Node Capabilities +

+
+ {advert.is_repeater && ( + + Repeater + + ) || null} + {advert.is_chat_node && ( + + Companion + + ) || null} + {advert.is_room_server && ( + + Room Server + + ) || null} + {!advert.is_repeater && !advert.is_chat_node && !advert.is_room_server && ( + + Unknown + + ) || null} +
+
+
+
+ )} +
+ ); +} diff --git a/src/components/ChatMessageItem.tsx b/src/components/ChatMessageItem.tsx index 9c8dc60..16cd068 100644 --- a/src/components/ChatMessageItem.tsx +++ b/src/components/ChatMessageItem.tsx @@ -1,10 +1,8 @@ "use client"; import React, { useState, useEffect, useMemo, useCallback } from "react"; -import { createPortal } from "react-dom"; import { useConfig } from "./ConfigContext"; import { decryptMeshcoreGroupMessage } from "../lib/meshcore"; -import Tree from 'react-d3-tree'; -import { ArrowsPointingOutIcon, ArrowsPointingInIcon } from "@heroicons/react/24/outline"; +import PathVisualization, { PathData } from "./PathVisualization"; export interface ChatMessage { ingest_timestamp: string; @@ -18,23 +16,6 @@ export interface ChatMessage { origin_key_path_array: Array<[string, string, string]>; // Array of [origin, pubkey, path] tuples } -interface PathGroup { - path: string; - pathSlices: string[]; // Includes origin pubkey as the final hop - indices: number[]; -} - -interface TreeNode { - name: string; - children?: TreeNode[]; - attributes?: Record; - [key: string]: any; -} - -// Type guard to ensure treeData is valid -const isValidTreeData = (data: TreeNode | null): data is TreeNode => { - return data !== null && typeof data === 'object' && 'name' in data; -}; function formatHex(hex: string): string { return hex.replace(/(.{2})/g, "$1 ").trim(); @@ -78,9 +59,6 @@ function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessage, showErrorRow const knownKeysString = knownKeys.join(","); const [parsed, setParsed] = useState(null); const [error, setError] = useState(null); - const [originsExpanded, setOriginsExpanded] = useState(false); - const [showGraph, setShowGraph] = useState(false); - const [graphFullscreen, setGraphFullscreen] = useState(false); useEffect(() => { let cancelled = false; @@ -116,216 +94,17 @@ function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessage, showErrorRow msg.origin_key_path_array && msg.origin_key_path_array.length > 0 ? msg.origin_key_path_array : [], [msg.origin_key_path_array] ); - const originsCount = originKeyPathArray.length; - // Process data for tree visualization - const treeData = useMemo(() => { - if (!showGraph || originsCount === 0) return null; + // Convert to PathData format for the new component + const pathData: PathData[] = useMemo(() => + originKeyPathArray.map(([origin, pubkey, path]) => ({ + origin, + pubkey, + path + })), + [originKeyPathArray] + ); - // Group messages by path similarity - const pathGroups: PathGroup[] = []; - - originKeyPathArray.forEach(([origin, pubkey, path], index) => { - // Parse path into 2-character slices and include pubkey as final hop - const pathSlices = path.match(/.{1,2}/g) || []; - const pubkeyPrefix = pubkey.substring(0, 2); - const fullPathSlices = [...pathSlices, pubkeyPrefix]; - - // Find existing group with same path structure - const existingGroup = pathGroups.find(group => - group.pathSlices.length === fullPathSlices.length && - group.pathSlices.every((slice, i) => slice === fullPathSlices[i]) - ); - - if (existingGroup) { - existingGroup.indices.push(index); - } else { - pathGroups.push({ - path: path + pubkeyPrefix, - pathSlices: fullPathSlices, - indices: [index] - }); - } - }); - - // Build tree structure for react-d3-tree - const buildTree = (): TreeNode => { - const root: TreeNode = { name: "??", children: [] }; - - pathGroups.forEach(group => { - let currentNode = root; - - group.pathSlices.forEach((slice, level) => { - let child = currentNode.children?.find(c => c.name === slice); - - if (!child) { - child = { name: slice, children: [] }; - if (!currentNode.children) currentNode.children = []; - currentNode.children.push(child); - } - - currentNode = child; - }); - }); - - return root; - }; - - return buildTree(); - }, [showGraph, originKeyPathArray, originsCount]); - - const handleOriginsToggle = useCallback(() => { - setOriginsExpanded(prev => !prev); - }, []); - - const handleGraphToggle = useCallback(() => { - setShowGraph(prev => !prev); - }, []); - - const handleFullscreenToggle = useCallback(() => { - setGraphFullscreen(prev => !prev); - }, []); - - const OriginsBox = useCallback(() => ( -
-
- - - {originsCount > 0 && ( - - )} -
- - {originsExpanded && originsCount > 0 && ( -
- {/* List view (original) */} - {originKeyPathArray.map(([origin, pubkey, path], index: number) => { - // Parse path into 2-character slices - const pathSlices = path.match(/.{1,2}/g) || []; - const formattedPath = pathSlices.join(' '); - // Get first 2 characters of the pubkey - const pubkeyPrefix = pubkey.substring(0, 2); - - return ( -
- {origin} - {formattedPath} - ({pubkeyPrefix}) -
- ); - })} -
- )} -
- ), [originsExpanded, originsCount, showGraph, originKeyPathArray, handleOriginsToggle, handleGraphToggle]); - - const GraphView = useCallback(() => { - if (!showGraph || originsCount === 0 || !treeData) return null; - - const renderTree = () => ( - { - const isRoot = nodeDatum.name === "??"; - // Check if this node represents an origin pubkey (final 2-char hex from pubkey) - const isOriginPubkey = originKeyPathArray.some(([origin, pubkey, path]) => { - const pubkeyPrefix = pubkey.substring(0, 2); - return nodeDatum.name === pubkeyPrefix; - }); - - return ( - - - - {nodeDatum.name} - - - ); - }} - /> - ); - - if (graphFullscreen && typeof window !== 'undefined') { - return createPortal( -
-
e.stopPropagation()} - > -
-

Message Propagation Tree

- -
-
- {renderTree()} -
-
-
, - document.body - ); - } - - return ( -
- -
- {renderTree()} -
-
- ); - }, [showGraph, originsCount, treeData, msg.ingest_timestamp, graphFullscreen, handleFullscreenToggle, originKeyPathArray]); if (parsed) { return ( @@ -340,8 +119,11 @@ function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessage, showErrorRow {parsed.sender && ": "} {linkifyText(parsed.text)} - - + ); } @@ -357,8 +139,11 @@ function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessage, showErrorRow
{error}
- - + ); } else { @@ -373,8 +158,11 @@ function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessage, showErrorRow channel: {msg.channel_hash}
- - +
); } diff --git a/src/components/ConfigContext.tsx b/src/components/ConfigContext.tsx index 3b9c10b..f3e834f 100644 --- a/src/components/ConfigContext.tsx +++ b/src/components/ConfigContext.tsx @@ -38,7 +38,7 @@ const DEFAULT_CONFIG: Config = { selectedRegion: undefined, // no region selected by default }; -const LAST_SEEN_OPTIONS = [ +export const LAST_SEEN_OPTIONS = [ { value: 1800, label: "30m" }, { value: 3600, label: "1h" }, { value: 7200, label: "2h" }, diff --git a/src/components/ContactQRCode.tsx b/src/components/ContactQRCode.tsx new file mode 100644 index 0000000..0147606 --- /dev/null +++ b/src/components/ContactQRCode.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import QRCode from "qrcode"; + +interface ContactQRCodeProps { + name: string; + publicKey: string; + type: number; + size?: number; +} + +export default function ContactQRCode({ name, publicKey, type, size = 200 }: ContactQRCodeProps) { + const canvasRef = useRef(null); + + useEffect(() => { + if (!canvasRef.current) return; + + const generateQR = async () => { + try { + const contactUrl = `meshcore://contact/add?name=${encodeURIComponent(name)}&public_key=${encodeURIComponent(publicKey)}&type=${type}`; + + await QRCode.toCanvas(canvasRef.current, contactUrl, { + width: size, + margin: 2, + color: { + dark: '#000000', + light: '#FFFFFF' + } + }); + } catch (error) { + console.error('Error generating QR code:', error); + } + }; + + generateQR(); + }, [name, publicKey, type, size]); + + return ( +
+ +
+ ); +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index fa6908a..c32361b 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -28,15 +28,15 @@ export default function Header({ configButtonRef }: HeaderProps) { const itemsRef = useRef(null); const dropdownRef = useRef(null); - // Define all navigation items - const allNavItems: NavItem[] = [ - { href: "/messages", label: "Messages" }, - { href: "/stats", label: "Stats" }, - { href: "/api-docs", label: "API Docs" }, - ]; - // Measure available space and determine which items can fit const measureAndLayout = useCallback(() => { + // Define all navigation items + const allNavItems: NavItem[] = [ + { href: "/messages", label: "Messages" }, + { href: "/stats", label: "Stats" }, + { href: "/search", label: "Search" }, + { href: "/api-docs", label: "API Docs" }, + ]; if (!navRef.current || !itemsRef.current) return; const navWidth = navRef.current.offsetWidth; diff --git a/src/components/MapView.tsx b/src/components/MapView.tsx index 58816d4..3163a44 100644 --- a/src/components/MapView.tsx +++ b/src/components/MapView.tsx @@ -1,7 +1,7 @@ "use client"; -import { MapContainer, TileLayer, useMapEvents, Marker, Popup, MapContainerProps, useMap } from "react-leaflet"; +import React, { useEffect, useMemo, useRef, useState, useCallback } from "react"; +import { MapContainer, TileLayer, useMapEvents, Marker, Popup, MapContainerProps, useMap, Polyline } from "react-leaflet"; import { useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import 'leaflet/dist/leaflet.css'; import L from "leaflet"; import 'leaflet.markercluster/dist/leaflet.markercluster.js'; @@ -13,6 +13,7 @@ import { NodeMarker, ClusterMarker, PopupContent } from "./MapIcons"; import { renderToString } from "react-dom/server"; import { buildApiUrl } from "../lib/api"; import { NodePosition } from "../types/map"; +import { useNeighbors, type Neighbor } from "../hooks/useNeighbors"; const DEFAULT = { lat: 46.56, // Center between Seattle and Portland @@ -20,12 +21,33 @@ const DEFAULT = { zoom: 7, // Zoom level to show both cities }; -type ClusteredMarkersProps = { nodes: NodePosition[] }; + +type ClusteredMarkersProps = { + nodes: NodePosition[]; + selectedNodeId: string | null; + onNodeClick: (nodeId: string | null) => void; +}; // Individual marker component -function IndividualMarker({ node, showNodeNames }: { node: NodePosition; showNodeNames: boolean }) { +function IndividualMarker({ + node, + showNodeNames, + selectedNodeId, + onNodeClick +}: { + node: NodePosition; + showNodeNames: boolean; + selectedNodeId: string | null; + onNodeClick: (nodeId: string | null) => void; +}) { const map = useMap(); const markerRef = useRef(null); + const onNodeClickRef = useRef(onNodeClick); + + // Keep the callback ref updated + useEffect(() => { + onNodeClickRef.current = onNodeClick; + }, [onNodeClick]); useEffect(() => { if (!map) return; @@ -40,6 +62,14 @@ function IndividualMarker({ node, showNodeNames }: { node: NodePosition; showNod const marker = L.marker([node.latitude, node.longitude], { icon }); (marker as any).options.nodeData = node; marker.bindPopup(renderToString()); + + // Add hover handler for meshcore nodes + if (node.type === "meshcore") { + marker.on('mouseover', () => { + onNodeClickRef.current(node.node_id); + }); + } + marker.addTo(map); markerRef.current = marker; @@ -48,7 +78,7 @@ function IndividualMarker({ node, showNodeNames }: { node: NodePosition; showNod map.removeLayer(markerRef.current); } }; - }, [map, node.latitude, node.longitude, node.node_id, showNodeNames]); + }, [map, node, showNodeNames]); // Update marker when node data changes useEffect(() => { @@ -74,9 +104,25 @@ function IndividualMarker({ node, showNodeNames }: { node: NodePosition; showNod } // Clustered markers component -function ClusteredMarkersGroup({ nodes, showNodeNames }: { nodes: NodePosition[]; showNodeNames: boolean }) { +function ClusteredMarkersGroup({ + nodes, + showNodeNames, + selectedNodeId, + onNodeClick +}: { + nodes: NodePosition[]; + showNodeNames: boolean; + selectedNodeId: string | null; + onNodeClick: (nodeId: string | null) => void; +}) { const map = useMap(); const clusterGroupRef = useRef(null); + const onNodeClickRef = useRef(onNodeClick); + + // Keep the callback ref updated + useEffect(() => { + onNodeClickRef.current = onNodeClick; + }, [onNodeClick]); useEffect(() => { if (!map) return; @@ -106,6 +152,14 @@ function ClusteredMarkersGroup({ nodes, showNodeNames }: { nodes: NodePosition[] const marker = L.marker([node.latitude, node.longitude], { icon }); (marker as any).options.nodeData = node; marker.bindPopup(renderToString()); + + // Add hover handler for meshcore nodes + if (node.type === "meshcore") { + marker.on('mouseover', () => { + onNodeClickRef.current(node.node_id); + }); + } + markers.addLayer(marker); }); @@ -123,7 +177,7 @@ function ClusteredMarkersGroup({ nodes, showNodeNames }: { nodes: NodePosition[] return null; } -function ClusteredMarkers({ nodes }: ClusteredMarkersProps) { +function ClusteredMarkers({ nodes, selectedNodeId, onNodeClick }: ClusteredMarkersProps) { const configResult = useConfig(); const config = configResult?.config; const showNodeNames = config?.showNodeNames !== false; @@ -136,7 +190,9 @@ function ClusteredMarkers({ nodes }: ClusteredMarkersProps) { ))} @@ -146,12 +202,77 @@ function ClusteredMarkers({ nodes }: ClusteredMarkersProps) { return ( ); } } +// Component to render neighbor lines with directional arrows +function NeighborLines({ + selectedNodeId, + neighbors, + nodes +}: { + selectedNodeId: string | null; + neighbors: Neighbor[]; + nodes: NodePosition[]; +}) { + if (!selectedNodeId || neighbors.length === 0) return null; + + // Find the selected node's position + const selectedNode = nodes.find(node => node.node_id === selectedNodeId); + if (!selectedNode) return null; + + // Create lines to neighbors that have location data and are visible on the map + const lines = neighbors + .filter(neighbor => neighbor.has_location && neighbor.latitude && neighbor.longitude) + .map(neighbor => { + // Check if the neighbor is also visible on the map + const neighborOnMap = nodes.find(node => node.node_id === neighbor.public_key); + + const hasIncoming = neighbor.directions?.includes('incoming') || false; + const hasOutgoing = neighbor.directions?.includes('outgoing') || false; + const isBidirectional = hasIncoming && hasOutgoing; + + return { + neighbor, + positions: [ + [selectedNode.latitude, selectedNode.longitude] as [number, number], + [neighbor.latitude!, neighbor.longitude!] as [number, number] + ], + isNeighborVisible: !!neighborOnMap, + hasIncoming, + hasOutgoing, + isBidirectional + }; + }); + + + return ( + <> + {lines.map(({ neighbor, positions, isNeighborVisible, isBidirectional }) => { + const lineColor = isNeighborVisible ? (isBidirectional ? '#10b981' : '#3b82f6') : '#94a3b8'; + + return ( + + ); + })} + + ); +} + export default function MapView() { const [nodePositions, setNodePositions] = useState([]); const [bounds, setBounds] = useState<[[number, number], [number, number]] | null>(null); @@ -161,6 +282,16 @@ export default function MapView() { const lastRequestedBounds = useRef<[[number, number], [number, number]] | null>(null); const configResult = useConfig(); const config = configResult?.config; + + // Neighbor-related state + const [selectedNodeId, setSelectedNodeId] = useState(null); + + // Use TanStack Query for neighbors data + const { data: neighbors = [], isLoading: neighborsLoading } = useNeighbors({ + nodeId: selectedNodeId, + lastSeen: config?.lastSeen, + enabled: !!selectedNodeId + }); type TileLayerKey = 'openstreetmap' | 'opentopomap' | 'esri'; const tileLayerOptions: Record = { @@ -182,6 +313,15 @@ export default function MapView() { }; const selectedTileLayer = tileLayerOptions[(config?.tileLayer as TileLayerKey) || 'openstreetmap']; + // Handle node hover + const handleNodeClick = useCallback((nodeId: string | null) => { + if (nodeId !== null && selectedNodeId !== nodeId) { + // Mouse over new node - set new selection (TanStack Query will handle fetching) + setSelectedNodeId(nodeId); + } + // Lines persist on mouseout and when hovering over same node + }, [selectedNodeId]); + const fetchNodes = useCallback((bounds?: [[number, number], [number, number]]) => { if (fetchController.current) { fetchController.current.abort(); @@ -357,7 +497,17 @@ export default function MapView() { opacity={0.7} /> )} - + + ); diff --git a/src/components/PathDisplay.tsx b/src/components/PathDisplay.tsx new file mode 100644 index 0000000..ff6f0b8 --- /dev/null +++ b/src/components/PathDisplay.tsx @@ -0,0 +1,29 @@ +"use client"; + +import React from "react"; + +interface PathDisplayProps { + path: string; + origin_pubkey: string; + className?: string; +} + +export default function PathDisplay({ + path, + origin_pubkey, + className = "" +}: PathDisplayProps) { + // Parse path into 2-character slices + const pathSlices = path.match(/.{1,2}/g) || []; + const formattedPath = pathSlices.join(' '); + + // Get first 2 characters of the pubkey for display + const pubkeyPrefix = origin_pubkey.substring(0, 2); + + return ( +
+ {formattedPath} + ({pubkeyPrefix}) +
+ ); +} diff --git a/src/components/PathVisualization.tsx b/src/components/PathVisualization.tsx new file mode 100644 index 0000000..7760605 --- /dev/null +++ b/src/components/PathVisualization.tsx @@ -0,0 +1,284 @@ +"use client"; + +import React, { useState, useMemo, useCallback } from "react"; +import { createPortal } from "react-dom"; +import Link from "next/link"; +import Tree from 'react-d3-tree'; +import { ArrowsPointingOutIcon, ArrowsPointingInIcon } from "@heroicons/react/24/outline"; +import PathDisplay from "./PathDisplay"; + +export interface PathData { + origin: string; + pubkey: string; + path: string; +} + +interface PathGroup { + path: string; + pathSlices: string[]; + indices: number[]; +} + +interface TreeNode { + name: string; + children?: TreeNode[]; +} + +interface PathVisualizationProps { + paths: PathData[]; + title?: string; + className?: string; + showDropdown?: boolean; + initiatingNodeKey?: string; +} + +export default function PathVisualization({ + paths, + title = "Paths", + className = "", + showDropdown = true, + initiatingNodeKey +}: PathVisualizationProps) { + const [expanded, setExpanded] = useState(false); + const [showGraph, setShowGraph] = useState(false); + const [graphFullscreen, setGraphFullscreen] = useState(false); + + const pathsCount = paths.length; + + // Process data for tree visualization + const treeData = useMemo(() => { + if (!showGraph || pathsCount === 0) return null; + + // Group messages by path similarity + const pathGroups: PathGroup[] = []; + + paths.forEach(({ origin, pubkey, path }, index) => { + // Parse path into 2-character slices and include pubkey as final hop + const pathSlices = path.match(/.{1,2}/g) || []; + const pubkeyPrefix = pubkey.substring(0, 2); + const fullPathSlices = [...pathSlices, pubkeyPrefix]; + + // Find existing group with same path structure + const existingGroup = pathGroups.find(group => + group.pathSlices.length === fullPathSlices.length && + group.pathSlices.every((slice, i) => slice === fullPathSlices[i]) + ); + + if (existingGroup) { + existingGroup.indices.push(index); + } else { + pathGroups.push({ + path: path + pubkeyPrefix, + pathSlices: fullPathSlices, + indices: [index] + }); + } + }); + + // Build tree structure for react-d3-tree + const buildTree = (): TreeNode => { + const rootName = initiatingNodeKey ? initiatingNodeKey.substring(0, 2) : "??"; + const root: TreeNode = { name: rootName, children: [] }; + + pathGroups.forEach(group => { + let currentNode = root; + + group.pathSlices.forEach((slice, level) => { + let child = currentNode.children?.find(c => c.name === slice); + + if (!child) { + child = { name: slice, children: [] }; + if (!currentNode.children) currentNode.children = []; + currentNode.children.push(child); + } + + currentNode = child; + }); + }); + + return root; + }; + + return buildTree(); + }, [showGraph, paths, pathsCount, initiatingNodeKey]); + + const handleToggle = useCallback(() => { + setExpanded(prev => !prev); + }, []); + + const handleGraphToggle = useCallback(() => { + setShowGraph(prev => !prev); + }, []); + + const handleFullscreenToggle = useCallback(() => { + setGraphFullscreen(prev => !prev); + }, []); + + const PathsList = useCallback(() => ( +
+ {paths.map(({ origin, pubkey, path }, index) => ( +
+ + {origin} + + +
+ ))} +
+ ), [paths]); + + const GraphView = useCallback(() => { + if (!showGraph || pathsCount === 0 || !treeData) return null; + + const renderTree = () => ( + { + const rootName = initiatingNodeKey ? initiatingNodeKey.substring(0, 2) : "??"; + const isRoot = nodeDatum.name === rootName; + // Check if this node represents an origin pubkey (final 2-char hex from pubkey) + const isOriginPubkey = paths.some(({ pubkey }) => { + const pubkeyPrefix = pubkey.substring(0, 2); + return nodeDatum.name === pubkeyPrefix; + }); + + return ( + + + + {nodeDatum.name} + + + ); + }} + /> + ); + + if (graphFullscreen && typeof window !== 'undefined') { + return createPortal( +
+
+
+

+ Path Visualization +

+ +
+
+
+ {renderTree()} +
+
+
+
, + document.body + ); + } + + return ( +
+
+ + Path Graph + + +
+
+ {renderTree()} +
+
+ ); + }, [showGraph, pathsCount, treeData, graphFullscreen, handleFullscreenToggle, paths, initiatingNodeKey]); + + if (!showDropdown) { + return ( +
+
+ + {pathsCount} path{pathsCount !== 1 ? 's' : ''} + + {pathsCount > 0 && ( + + )} +
+ + {showGraph && } +
+ ); + } + + return ( +
+
+ + + {pathsCount > 0 && ( + + )} +
+ + {expanded && pathsCount > 0 && ( + + )} + + +
+ ); +} diff --git a/src/components/QueryProvider.tsx b/src/components/QueryProvider.tsx new file mode 100644 index 0000000..43f2a82 --- /dev/null +++ b/src/components/QueryProvider.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactNode, useState } from 'react'; + +export function QueryProvider({ children }: { children: ReactNode }) { + const [queryClient] = useState(() => new QueryClient({ + defaultOptions: { + queries: { + staleTime: 15 * 60 * 1000, // 15 minutes + gcTime: 15 * 60 * 1000, // 15 minutes (formerly cacheTime) + refetchOnWindowFocus: false, + retry: 1, + }, + }, + })); + + return ( + + {children} + + ); +} diff --git a/src/components/SearchInput.tsx b/src/components/SearchInput.tsx new file mode 100644 index 0000000..e6fb246 --- /dev/null +++ b/src/components/SearchInput.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useState, useEffect, useRef } from 'react'; +import { MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/outline'; + +interface SearchInputProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; + autoFocus?: boolean; +} + +export default function SearchInput({ + value, + onChange, + placeholder = "Search nodes by name or public key...", + className = "", + autoFocus = false +}: SearchInputProps) { + const [localValue, setLocalValue] = useState(value); + const inputRef = useRef(null); + const isUpdatingFromProps = useRef(false); + + // Update local value when prop changes + useEffect(() => { + if (value !== localValue) { + isUpdatingFromProps.current = true; + setLocalValue(value); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]); + + // Call onChange when local value changes (but not when updating from props) + useEffect(() => { + if (isUpdatingFromProps.current) { + isUpdatingFromProps.current = false; + return; + } + + if (localValue !== value) { + onChange(localValue); + } + }, [localValue, onChange, value]); + + const handleClear = () => { + setLocalValue(''); + onChange(''); + inputRef.current?.focus(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + handleClear(); + } + }; + + return ( +
+
+ + setLocalValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + autoFocus={autoFocus} + className="w-full pl-10 pr-10 py-3 border border-gray-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> + {localValue && ( + + )} +
+
+ ); +} diff --git a/src/components/SearchResults.tsx b/src/components/SearchResults.tsx new file mode 100644 index 0000000..73c19d6 --- /dev/null +++ b/src/components/SearchResults.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { MeshcoreSearchResult } from '@/hooks/useMeshcoreSearch'; +import { MapPinIcon, WifiIcon, ChatBubbleLeftRightIcon, ServerIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline'; +import Link from 'next/link'; +import moment from 'moment'; + +interface SearchResultsProps { + results: MeshcoreSearchResult[]; + isLoading: boolean; + error: Error | null; + query: string; + total: number; +} + +export default function SearchResults({ results, isLoading, error, query, total }: SearchResultsProps) { + if (isLoading) { + return ( +
+ {[...Array(3)].map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ); + } + + if (error) { + return ( +
+
+ +

Search Error

+
+

+ Failed to search nodes. Please try again. +

+
+ ); + } + + if (!query.trim()) { + return ( +
+ +

+ Search MeshCore Nodes +

+

+ Enter a node name or public key to search for nodes +

+
+ ); + } + + if (results.length === 0) { + return ( +
+ +

+ No Results Found +

+

+ No nodes found for "{query}". Try a different search term. +

+
+ ); + } + + return ( +
+
+

+ Search Results +

+ + {total} {total === 1 ? 'node' : 'nodes'} found + +
+ +
+ {results.map((node) => ( + + ))} +
+
+ ); +} + +function SearchResultItem({ node }: { node: MeshcoreSearchResult }) { + const lastSeen = new Date(node.last_seen); + const hasLocation = node.has_location === 1; + const isRepeater = node.is_repeater === 1; + const isChatNode = node.is_chat_node === 1; + const isRoomServer = node.is_room_server === 1; + + return ( + +
+
+
+

+ {node.node_name || 'Unnamed Node'} +

+
+ {isRepeater && ( + + + Repeater + + )} + {isChatNode && ( + + + Chat + + )} + {isRoomServer && ( + + + Room Server + + )} +
+
+ +
+ {hasLocation && node.latitude && node.longitude && ( +
+ + + {node.latitude.toFixed(4)}, {node.longitude.toFixed(4)} + +
+ )} + +
+ Last seen: {moment(lastSeen).fromNow()} + + {node.public_key.substring(0, 8)}... + +
+ +
+ Topic: {node.topic} • Broker: {node.broker.split('://')[1]} +
+
+
+
+ + ); +} diff --git a/src/hooks/useMeshcoreSearch.ts b/src/hooks/useMeshcoreSearch.ts new file mode 100644 index 0000000..70ae391 --- /dev/null +++ b/src/hooks/useMeshcoreSearch.ts @@ -0,0 +1,79 @@ +import { useQuery } from '@tanstack/react-query'; +import { buildApiUrl } from '../lib/api'; + +export interface MeshcoreSearchResult { + public_key: string; + node_name: string; + latitude: number | null; + longitude: number | null; + has_location: number; + is_repeater: number; + is_chat_node: number; + is_room_server: number; + has_name: number; + first_heard: string; + last_seen: string; + broker: string; + topic: string; +} + +export interface MeshcoreSearchResponse { + results: MeshcoreSearchResult[]; + total: number; + query: string; + region: string | null; + lastSeen: string | null; + limit: number; +} + +interface UseMeshcoreSearchParams { + query: string; + region?: string; + lastSeen?: number | null; + limit?: number; + enabled?: boolean; +} + +export function useMeshcoreSearch({ + query, + region, + lastSeen, + limit = 50, + enabled = true +}: UseMeshcoreSearchParams) { + return useQuery({ + queryKey: ['meshcore-search', query, region, lastSeen, limit], + queryFn: async ({ signal }): Promise => { + const params = new URLSearchParams(); + + if (query.trim()) { + params.append('q', query.trim()); + } + if (region) { + params.append('region', region); + } + if (lastSeen !== null && lastSeen !== undefined) { + params.append('lastSeen', lastSeen.toString()); + } + if (limit !== 50) { + params.append('limit', limit.toString()); + } + + const url = `/api/meshcore/search${params.toString() ? `?${params.toString()}` : ''}`; + + const response = await fetch(buildApiUrl(url), { + signal, // Use the AbortSignal from TanStack Query + }); + + if (!response.ok) { + throw new Error(`Failed to search meshcore nodes: ${response.statusText}`); + } + + return response.json(); + }, + enabled: enabled && query.trim().length > 0, + staleTime: 30 * 1000, // 30 seconds - shorter for search results + gcTime: 5 * 60 * 1000, // 5 minutes + retry: 1, + }); +} diff --git a/src/hooks/useNeighbors.ts b/src/hooks/useNeighbors.ts new file mode 100644 index 0000000..c3d83c7 --- /dev/null +++ b/src/hooks/useNeighbors.ts @@ -0,0 +1,46 @@ +import { useQuery } from '@tanstack/react-query'; +import { buildApiUrl } from '../lib/api'; + +export interface Neighbor { + public_key: string; + node_name: string; + latitude: number | null; + longitude: number | null; + has_location: number; + is_repeater: number; + is_chat_node: number; + is_room_server: number; + has_name: number; + directions: string[]; +} + +interface UseNeighborsParams { + nodeId: string | null; + lastSeen?: number | null; + enabled?: boolean; +} + +export function useNeighbors({ nodeId, lastSeen, enabled = true }: UseNeighborsParams) { + return useQuery({ + queryKey: ['neighbors', nodeId, lastSeen], + queryFn: async (): Promise => { + if (!nodeId) return []; + + const params = new URLSearchParams(); + if (lastSeen !== null && lastSeen !== undefined) { + params.append('lastSeen', lastSeen.toString()); + } + const url = `/api/meshcore/node/${nodeId}/neighbors${params.toString() ? `?${params.toString()}` : ''}`; + + const response = await fetch(buildApiUrl(url)); + if (!response.ok) { + throw new Error(`Failed to fetch neighbors: ${response.statusText}`); + } + + return response.json(); + }, + enabled: enabled && !!nodeId, + staleTime: 15 * 60 * 1000, // 15 minutes + gcTime: 15 * 60 * 1000, // 15 minutes + }); +} diff --git a/src/hooks/useSearchQuery.ts b/src/hooks/useSearchQuery.ts new file mode 100644 index 0000000..d46334e --- /dev/null +++ b/src/hooks/useSearchQuery.ts @@ -0,0 +1,124 @@ +"use client"; + +import { useRouter, useSearchParams } from 'next/navigation'; +import { useCallback, useMemo, useState, useEffect, useRef } from 'react'; + +export function useQueryParams>(defaultValues: T = {} as T) { + const router = useRouter(); + const searchParams = useSearchParams(); + const [internalState, setInternalState] = useState(() => { + const result = { ...defaultValues }; + + // Initialize from search params on mount + if (typeof window !== 'undefined') { + const urlParams = new URLSearchParams(window.location.search); + urlParams.forEach((value, key) => { + if (!isNaN(Number(value)) && value !== '') { + result[key as keyof T] = Number(value) as T[keyof T]; + } else { + result[key as keyof T] = value as T[keyof T]; + } + }); + } + + return result; + }); + + const internalStateRef = useRef(internalState); + internalStateRef.current = internalState; + + // Only update internal state when searchParams change from external navigation + // (not from our own updates) + useEffect(() => { + const newState = { ...defaultValues }; + + searchParams.forEach((value, key) => { + if (!isNaN(Number(value)) && value !== '') { + newState[key as keyof T] = Number(value) as T[keyof T]; + } else { + newState[key as keyof T] = value as T[keyof T]; + } + }); + + // Only update if the state is actually different + const stateChanged = JSON.stringify(newState) !== JSON.stringify(internalStateRef.current); + if (stateChanged) { + setInternalState(newState); + } + }, [searchParams, defaultValues]); + + const query = internalState; + + const updateQuery = useCallback((updates: Partial) => { + const newState = { ...internalState, ...updates }; + setInternalState(newState); + + const newSearchParams = new URLSearchParams(); + + Object.entries(newState).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '' && value !== defaultValues[key as keyof T]) { + newSearchParams.set(key, value.toString()); + } + }); + + const newUrl = `${window.location.pathname}${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ''}`; + + // Use native History API for shallow-like routing + window.history.replaceState(null, '', newUrl); + }, [internalState, defaultValues]); + + const setParam = useCallback((key: K, value: T[K]) => { + updateQuery({ [key]: value } as unknown as Partial); + }, [updateQuery]); + + const clearParam = useCallback((key: keyof T) => { + const newState = { ...internalState }; + delete newState[key]; + setInternalState(newState); + + const newSearchParams = new URLSearchParams(); + + Object.entries(newState).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== '' && v !== defaultValues[k as keyof T]) { + newSearchParams.set(k, v.toString()); + } + }); + + const newUrl = `${window.location.pathname}${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ''}`; + window.history.replaceState(null, '', newUrl); + }, [internalState, defaultValues]); + + const clearAll = useCallback(() => { + setInternalState({ ...defaultValues }); + window.history.replaceState(null, '', window.location.pathname); + }, [defaultValues]); + + return { + query, + updateQuery, + setParam, + clearParam, + clearAll, + }; +} + +// Legacy hook for backward compatibility - now uses the generic hook +export interface SearchQuery { + q: string; + limit?: number; +} + +export function useSearchQuery() { + const { query, setParam } = useQueryParams({ q: '', limit: 50 }); + + return { + query, + setQuery: (q: string) => setParam('q', q), + setLimit: (limit: number) => setParam('limit', limit), + updateQuery: (updates: Partial) => { + Object.entries(updates).forEach(([key, value]) => { + setParam(key as keyof SearchQuery, value as any); + }); + }, + }; +} diff --git a/src/lib/clickhouse/actions.ts b/src/lib/clickhouse/actions.ts index 4d44249..fa669fb 100644 --- a/src/lib/clickhouse/actions.ts +++ b/src/lib/clickhouse/actions.ts @@ -1,6 +1,6 @@ "use server"; import { clickhouse } from "./clickhouse"; -import { generateRegionWhereClauseFromArray } from "../regionFilters"; +import { generateRegionWhereClauseFromArray, generateRegionWhereClause } from "../regionFilters"; export async function getNodePositions({ minLat, maxLat, minLng, maxLng, nodeTypes, lastSeen }: { minLat?: string | null, maxLat?: string | null, minLng?: string | null, maxLng?: string | null, nodeTypes?: string[], lastSeen?: string | null } = {}) { try { @@ -97,22 +97,23 @@ export async function getLatestChatMessages({ limit = 20, before, after, channel export async function getMeshcoreNodeInfo(publicKey: string, limit: number = 50) { try { - // Get basic node info from the latest advert + // Get basic node info from the latest advert and first seen time const nodeInfoQuery = ` SELECT public_key, - node_name, - latitude, - longitude, - has_location, - is_repeater, - is_chat_node, - is_room_server, - has_name, - mesh_timestamp as last_seen + argMax(node_name, ingest_timestamp) as node_name, + argMax(latitude, ingest_timestamp) as latitude, + argMax(longitude, ingest_timestamp) as longitude, + argMax(has_location, ingest_timestamp) as has_location, + argMax(is_repeater, ingest_timestamp) as is_repeater, + argMax(is_chat_node, ingest_timestamp) as is_chat_node, + argMax(is_room_server, ingest_timestamp) as is_room_server, + argMax(has_name, ingest_timestamp) as has_name, + max(ingest_timestamp) as last_seen, + min(ingest_timestamp) as first_seen FROM meshcore_adverts WHERE public_key = {publicKey:String} - ORDER BY mesh_timestamp DESC + GROUP BY public_key LIMIT 1 `; @@ -127,23 +128,41 @@ export async function getMeshcoreNodeInfo(publicKey: string, limit: number = 50) return null; } - // Get recent adverts with path information + // Get recent adverts grouped by adv_timestamp with origin_path_pubkey tuples const advertsQuery = ` SELECT - mesh_timestamp, - hex(path) as path, - path_len, - latitude, - longitude, - is_repeater, - is_chat_node, - is_room_server, - has_location, - hex(origin_pubkey) as origin_pubkey, - concat(path, substring(origin_pubkey, 1, 2)) as full_path - FROM meshcore_adverts - WHERE public_key = {publicKey:String} - ORDER BY mesh_timestamp DESC + adv_timestamp, + groupArray((origin, path, origin_pubkey)) as origin_path_pubkey_tuples, + count() as advert_count, + min(ingest_timestamp) as earliest_timestamp, + max(ingest_timestamp) as latest_timestamp, + argMax(latitude, ingest_timestamp) as latitude, + argMax(longitude, ingest_timestamp) as longitude, + argMax(is_repeater, ingest_timestamp) as is_repeater, + argMax(is_chat_node, ingest_timestamp) as is_chat_node, + argMax(is_room_server, ingest_timestamp) as is_room_server, + argMax(has_location, ingest_timestamp) as has_location + FROM ( + SELECT + ingest_timestamp, + mesh_timestamp, + adv_timestamp, + hex(path) as path, + path_len, + latitude, + longitude, + is_repeater, + is_chat_node, + is_room_server, + has_location, + hex(origin_pubkey) as origin_pubkey, + origin + FROM meshcore_adverts + WHERE public_key = {publicKey:String} + ORDER BY ingest_timestamp DESC + ) + GROUP BY adv_timestamp + ORDER BY max(ingest_timestamp) DESC LIMIT {limit:UInt32} `; @@ -159,19 +178,12 @@ export async function getMeshcoreNodeInfo(publicKey: string, limit: number = 50) SELECT mesh_timestamp, latitude, - longitude, - hex(path) as path, - path_len, - hex(origin_pubkey) as origin_pubkey, - concat(hex(path), substring(hex(origin_pubkey), 1, 4)) as full_path + longitude FROM ( SELECT mesh_timestamp, latitude, longitude, - path, - path_len, - origin_pubkey, row_number() OVER (PARTITION BY round(latitude, 6), round(longitude, 6) ORDER BY mesh_timestamp DESC) as rn FROM meshcore_adverts WHERE public_key = {publicKey:String} @@ -191,14 +203,17 @@ export async function getMeshcoreNodeInfo(publicKey: string, limit: number = 50) }); const locationHistory = await locationResult.json(); - // Check MQTT uplink status and last packet time + // Check MQTT uplink status and last packet time per topic const mqttQuery = ` SELECT - count() > 0 as has_packets, - max(ingest_timestamp) as last_uplink_time, - max(ingest_timestamp) >= now() - INTERVAL 7 DAY as is_uplinked + topic, + broker, + max(ingest_timestamp) as last_packet_time, + max(ingest_timestamp) >= now() - INTERVAL 7 DAY as is_recent FROM meshcore_packets WHERE hex(origin_pubkey) = {publicKey:String} + GROUP BY topic, broker + ORDER BY last_packet_time DESC `; const mqttResult = await clickhouse.query({ @@ -206,16 +221,241 @@ export async function getMeshcoreNodeInfo(publicKey: string, limit: number = 50) query_params: { publicKey }, format: 'JSONEachRow' }); - const mqttData = await mqttResult.json(); + const mqttTopics = await mqttResult.json() as Array<{ + topic: string; + broker: string; + last_packet_time: string; + is_recent: boolean; + }>; + + // Calculate overall MQTT status + const hasPackets = mqttTopics.length > 0; + const isUplinked = mqttTopics.some(topic => topic.is_recent); return { node: nodeInfo[0], recentAdverts: adverts, locationHistory: locationHistory, - mqtt: mqttData[0] || { is_uplinked: false, last_uplink_time: null } + mqtt: { + is_uplinked: isUplinked, + has_packets: hasPackets, + topics: mqttTopics + } }; } catch (error) { console.error('ClickHouse error in getMeshcoreNodeInfo:', error); throw error; } +} + +export async function getMeshcoreNodeNeighbors(publicKey: string, lastSeen: string | null = null) { + try { + // Build base where conditions for both directions + let baseWhereConditions = []; + const params: Record = { publicKey }; + + // Add lastSeen filter if provided + if (lastSeen !== null) { + baseWhereConditions.push("ingest_timestamp >= now() - INTERVAL {lastSeen:UInt32} SECOND"); + params.lastSeen = Number(lastSeen); + } + + const baseWhere = baseWhereConditions.length > 0 ? `AND ${baseWhereConditions.join(" AND ")}` : ''; + + const neighborsQuery = ` + SELECT + public_key, + argMax(node_name, timestamp_ref) as node_name, + argMax(latitude, timestamp_ref) as latitude, + argMax(longitude, timestamp_ref) as longitude, + argMax(has_location, timestamp_ref) as has_location, + argMax(is_repeater, timestamp_ref) as is_repeater, + argMax(is_chat_node, timestamp_ref) as is_chat_node, + argMax(is_room_server, timestamp_ref) as is_room_server, + argMax(has_name, timestamp_ref) as has_name, + groupUniqArray(direction) as directions + FROM ( + -- Direction 1: Adverts heard directly by the queried node + -- (hex(origin_pubkey) is the queried node, public_key is the neighbor) + SELECT + public_key, + node_name, + latitude, + longitude, + has_location, + is_repeater, + is_chat_node, + is_room_server, + has_name, + ingest_timestamp as timestamp_ref, + 'incoming' as direction + FROM meshcore_adverts + WHERE hex(origin_pubkey) = {publicKey:String} + AND path_len = 0 + AND public_key != {publicKey:String} + ${baseWhere} + + UNION ALL + + -- Direction 2: Adverts from the queried node heard by other nodes + -- (public_key is the queried node, origin_pubkey is the neighbor) + -- Use a subquery to get neighbor node attributes + SELECT + neighbor.public_key, + neighbor.node_name, + neighbor.latitude, + neighbor.longitude, + neighbor.has_location, + neighbor.is_repeater, + neighbor.is_chat_node, + neighbor.is_room_server, + neighbor.has_name, + adv.ingest_timestamp as timestamp_ref, + 'outgoing' as direction + FROM ( + SELECT DISTINCT + hex(origin_pubkey) as neighbor_key, + ingest_timestamp + FROM meshcore_adverts + WHERE public_key = {publicKey:String} + AND path_len = 0 + AND hex(origin_pubkey) != {publicKey:String} + ${baseWhere} + ) adv + LEFT JOIN ( + SELECT DISTINCT + public_key, + argMax(node_name, ingest_timestamp) as node_name, + argMax(latitude, ingest_timestamp) as latitude, + argMax(longitude, ingest_timestamp) as longitude, + argMax(has_location, ingest_timestamp) as has_location, + argMax(is_repeater, ingest_timestamp) as is_repeater, + argMax(is_chat_node, ingest_timestamp) as is_chat_node, + argMax(is_room_server, ingest_timestamp) as is_room_server, + argMax(has_name, ingest_timestamp) as has_name + FROM meshcore_adverts + GROUP BY public_key + ) neighbor ON adv.neighbor_key = neighbor.public_key + ) AS combined_neighbors + GROUP BY public_key + ORDER BY public_key + `; + + const neighborsResult = await clickhouse.query({ + query: neighborsQuery, + query_params: params, + format: 'JSONEachRow' + }); + const neighbors = await neighborsResult.json(); + + return neighbors as Array<{ + public_key: string; + node_name: string; + latitude: number | null; + longitude: number | null; + has_location: number; + is_repeater: number; + is_chat_node: number; + is_room_server: number; + has_name: number; + directions: string[]; + }>; + } catch (error) { + console.error('ClickHouse error in getMeshcoreNodeNeighbors:', error); + throw error; + } +} + +export async function searchMeshcoreNodes({ + query: searchQuery, + region, + lastSeen, + limit = 50 +}: { + query?: string; + region?: string; + lastSeen?: string | null; + limit?: number; +} = {}) { + try { + let where = []; + const params: Record = { limit }; + + // Add search conditions + if (searchQuery && searchQuery.trim()) { + const trimmedQuery = searchQuery.trim(); + + // Check if it looks like a public key (hex string) + if (/^[0-9A-Fa-f]+$/.test(trimmedQuery)) { + // Search by public key prefix + where.push('public_key LIKE {publicKeyPattern:String}'); + params.publicKeyPattern = `${trimmedQuery.toUpperCase()}%`; + } else { + // Search by node name (case insensitive, anywhere in the name) + where.push('lower(node_name) LIKE {namePattern:String}'); + params.namePattern = `%${trimmedQuery.toLowerCase()}%`; + } + } + + // Add lastSeen filter if provided + if (lastSeen !== null && lastSeen !== undefined && lastSeen !== "") { + where.push('last_seen >= now() - INTERVAL {lastSeen:UInt32} SECOND'); + params.lastSeen = Number(lastSeen); + } + + // Add region filtering if specified + const regionFilter = generateRegionWhereClause(region); + if (regionFilter.whereClause) { + where.push(regionFilter.whereClause); + } + + const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; + + const query = ` + SELECT + public_key, + node_name, + latitude, + longitude, + has_location, + is_repeater, + is_chat_node, + is_room_server, + has_name, + first_heard, + last_seen, + broker, + topic + FROM meshcore_adverts_latest + ${whereClause} + ORDER BY last_seen DESC + LIMIT {limit:UInt32} + `; + + const resultSet = await clickhouse.query({ + query, + query_params: params, + format: 'JSONEachRow' + }); + const rows = await resultSet.json(); + + return rows as Array<{ + public_key: string; + node_name: string; + latitude: number | null; + longitude: number | null; + has_location: number; + is_repeater: number; + is_chat_node: number; + is_room_server: number; + has_name: number; + first_heard: string; + last_seen: string; + broker: string; + topic: string; + }>; + } catch (error) { + console.error('ClickHouse error in searchMeshcoreNodes:', error); + throw error; + } } \ No newline at end of file