mirror of
https://github.com/ajvpot/meshexplorer.git
synced 2026-03-28 17:42:58 +01:00
Node info page improvements, search, neighbors
This commit is contained in:
283
package-lock.json
generated
283
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
28
public/favicon.svg
Normal file
28
public/favicon.svg
Normal file
@@ -0,0 +1,28 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||
<defs>
|
||||
<linearGradient id="meshGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#3b82f6;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1d4ed8;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background circle -->
|
||||
<circle cx="16" cy="16" r="15" fill="url(#meshGradient)" stroke="#1e40af" stroke-width="1"/>
|
||||
|
||||
<!-- Mesh nodes -->
|
||||
<circle cx="8" cy="8" r="2" fill="#ffffff" opacity="0.9"/>
|
||||
<circle cx="24" cy="8" r="2" fill="#ffffff" opacity="0.9"/>
|
||||
<circle cx="8" cy="24" r="2" fill="#ffffff" opacity="0.9"/>
|
||||
<circle cx="24" cy="24" r="2" fill="#ffffff" opacity="0.9"/>
|
||||
<circle cx="16" cy="16" r="2.5" fill="#ffffff" opacity="1"/>
|
||||
|
||||
<!-- Connection lines -->
|
||||
<line x1="8" y1="8" x2="16" y2="16" stroke="#ffffff" stroke-width="1.5" opacity="0.8"/>
|
||||
<line x1="24" y1="8" x2="16" y2="16" stroke="#ffffff" stroke-width="1.5" opacity="0.8"/>
|
||||
<line x1="8" y1="24" x2="16" y2="16" stroke="#ffffff" stroke-width="1.5" opacity="0.8"/>
|
||||
<line x1="24" y1="24" x2="16" y2="16" stroke="#ffffff" stroke-width="1.5" opacity="0.8"/>
|
||||
<line x1="8" y1="8" x2="24" y2="8" stroke="#ffffff" stroke-width="1" opacity="0.6"/>
|
||||
<line x1="8" y1="24" x2="24" y2="24" stroke="#ffffff" stroke-width="1" opacity="0.6"/>
|
||||
<line x1="8" y1="8" x2="8" y2="24" stroke="#ffffff" stroke-width="1" opacity="0.6"/>
|
||||
<line x1="24" y1="8" x2="24" y2="24" stroke="#ffffff" stroke-width="1" opacity="0.6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -209,7 +209,7 @@ export default function ApiDocsPage() {
|
||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Meshcore Node API</h2>
|
||||
<div className="bg-gray-50 dark:bg-neutral-800 rounded-lg p-6 mb-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">GET /api/meshcore/node/{`{publicKey}`}</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4">Retrieve detailed information about a specific meshcore node including recent adverts and location history.</p>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4">Retrieve detailed information about a specific meshcore node including recent adverts, location history, and MQTT uplink status.</p>
|
||||
|
||||
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">Path Parameters</h4>
|
||||
<div className="overflow-x-auto">
|
||||
@@ -255,6 +255,63 @@ export default function ApiDocsPage() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2 mt-6">Response Fields</h4>
|
||||
<div className="mb-4">
|
||||
<h5 className="font-medium text-gray-900 dark:text-gray-100 mb-2">Node Object</h5>
|
||||
<ul className="text-sm text-gray-600 dark:text-gray-300 space-y-1 ml-4">
|
||||
<li><code className="bg-gray-200 dark:bg-neutral-700 px-1 rounded">public_key</code> - The node's public key identifier</li>
|
||||
<li><code className="bg-gray-200 dark:bg-neutral-700 px-1 rounded">node_name</code> - Display name of the node</li>
|
||||
<li><code className="bg-gray-200 dark:bg-neutral-700 px-1 rounded">latitude/longitude</code> - Current position coordinates</li>
|
||||
<li><code className="bg-gray-200 dark:bg-neutral-700 px-1 rounded">has_location</code> - Whether the node has location data (0/1)</li>
|
||||
<li><code className="bg-gray-200 dark:bg-neutral-700 px-1 rounded">is_repeater</code> - Whether the node acts as a repeater (0/1)</li>
|
||||
<li><code className="bg-gray-200 dark:bg-neutral-700 px-1 rounded">is_chat_node</code> - Whether the node supports chat (0/1)</li>
|
||||
<li><code className="bg-gray-200 dark:bg-neutral-700 px-1 rounded">is_room_server</code> - Whether the node is a room server (0/1)</li>
|
||||
<li><code className="bg-gray-200 dark:bg-neutral-700 px-1 rounded">has_name</code> - Whether the node has a name (0/1)</li>
|
||||
<li><code className="bg-gray-200 dark:bg-neutral-700 px-1 rounded">last_seen</code> - Most recent activity timestamp</li>
|
||||
<li><code className="bg-gray-200 dark:bg-neutral-700 px-1 rounded">first_seen</code> - First time this node was observed</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h5 className="font-medium text-gray-900 dark:text-gray-100 mb-2">Recent Adverts Array</h5>
|
||||
<ul className="text-sm text-gray-600 dark:text-gray-300 space-y-1 ml-4">
|
||||
<li><code className="bg-gray-200 dark:bg-neutral-700 px-1 rounded">adv_timestamp</code> - Advertisement timestamp</li>
|
||||
<li><code className="bg-gray-200 dark:bg-neutral-700 px-1 rounded">origin_path_pubkey_tuples</code> - Array of [origin, path, pubkey] tuples</li>
|
||||
<li><code className="bg-gray-200 dark:bg-neutral-700 px-1 rounded">advert_count</code> - Number of adverts in this group</li>
|
||||
<li><code className="bg-gray-200 dark:bg-neutral-700 px-1 rounded">earliest_timestamp/latest_timestamp</code> - Time range for this advert group</li>
|
||||
<li><code className="bg-gray-200 dark:bg-neutral-700 px-1 rounded">latitude/longitude</code> - Position at time of advert</li>
|
||||
<li><code className="bg-gray-200 dark:bg-neutral-700 px-1 rounded">is_repeater/is_chat_node/is_room_server/has_location</code> - Node capabilities at time of advert</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h5 className="font-medium text-gray-900 dark:text-gray-100 mb-2">Location History Array</h5>
|
||||
<ul className="text-sm text-gray-600 dark:text-gray-300 space-y-1 ml-4">
|
||||
<li><code className="bg-gray-200 dark:bg-neutral-700 px-1 rounded">mesh_timestamp</code> - When this location was recorded</li>
|
||||
<li><code className="bg-gray-200 dark:bg-neutral-700 px-1 rounded">latitude/longitude</code> - Location coordinates</li>
|
||||
<li>Limited to last 30 days, deduplicated by location</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h5 className="font-medium text-gray-900 dark:text-gray-100 mb-2">MQTT Object</h5>
|
||||
<ul className="text-sm text-gray-600 dark:text-gray-300 space-y-1 ml-4">
|
||||
<li><code className="bg-gray-200 dark:bg-neutral-700 px-1 rounded">has_packets</code> - Whether any MQTT packets have been received</li>
|
||||
<li><code className="bg-gray-200 dark:bg-neutral-700 px-1 rounded">is_uplinked</code> - Whether node has been active in last 7 days</li>
|
||||
<li><code className="bg-gray-200 dark:bg-neutral-700 px-1 rounded">topics</code> - Array of MQTT topic information</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h5 className="font-medium text-gray-900 dark:text-gray-100 mb-2">MQTT Topics Array</h5>
|
||||
<ul className="text-sm text-gray-600 dark:text-gray-300 space-y-1 ml-4">
|
||||
<li><code className="bg-gray-200 dark:bg-neutral-700 px-1 rounded">topic</code> - MQTT topic name (e.g., "meshcore", "meshcore/pdx")</li>
|
||||
<li><code className="bg-gray-200 dark:bg-neutral-700 px-1 rounded">broker</code> - MQTT broker URL</li>
|
||||
<li><code className="bg-gray-200 dark:bg-neutral-700 px-1 rounded">last_packet_time</code> - Most recent packet timestamp for this topic</li>
|
||||
<li><code className="bg-gray-200 dark:bg-neutral-700 px-1 rounded">is_recent</code> - Whether this topic has been active in last 7 days (0/1)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2 mt-6">Response Format</h4>
|
||||
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 overflow-x-auto">
|
||||
<pre className="text-green-400 dark:text-green-300 text-sm">
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -531,6 +609,7 @@ export default function ApiDocsPage() {
|
||||
{`GET /api/meshcore/node/82D396A8754609E302A2A3FDB9210A1C67C7081606C16A89F77AD75C16E1DA1A?limit=100`}
|
||||
</pre>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mt-2">This will return detailed information about the specified meshcore node including recent adverts, location history, and MQTT uplink status.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
55
src/app/api/meshcore/node/[publicKey]/neighbors/route.ts
Normal file
55
src/app/api/meshcore/node/[publicKey]/neighbors/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
82
src/app/api/meshcore/search/route.ts
Normal file
82
src/app/api/meshcore/search/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -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}
|
||||
>
|
||||
<div className="flex flex-col min-h-screen w-full">
|
||||
<ConfigProvider>
|
||||
<Header />
|
||||
<main className="flex-1 flex flex-col w-full bg-neutral-200 dark:bg-neutral-800">{children}</main>
|
||||
</ConfigProvider>
|
||||
<QueryProvider>
|
||||
<ConfigProvider>
|
||||
<Header />
|
||||
<main className="flex-1 flex flex-col w-full bg-neutral-200 dark:bg-neutral-800">{children}</main>
|
||||
</ConfigProvider>
|
||||
</QueryProvider>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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<NodeData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errorCode, setErrorCode] = useState<string | null>(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 (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-neutral-800 flex items-center justify-center">
|
||||
@@ -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 (
|
||||
<div className="space-y-2">
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
The node with public key <code className="bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-sm font-mono">{formatPublicKey(publicKey)}</code> was not found in the database.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
This could mean the node has never been seen on the mesh network, or the public key is incorrect.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
case "INVALID_PUBLIC_KEY":
|
||||
case "MISSING_PUBLIC_KEY":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
The provided public key is invalid or missing.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Please check the URL and try again with a valid public key.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
case "SERVICE_UNAVAILABLE":
|
||||
case "DATABASE_ERROR":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
The database is temporarily unavailable.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Please try again in a few moments.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
case "NETWORK_ERROR":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
Unable to connect to the server.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Please check your internet connection and try again.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return <p className="text-gray-600 dark:text-gray-300">{error}</p>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-neutral-800 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-red-600 dark:text-red-400 text-6xl mb-4">⚠️</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">Error</h1>
|
||||
<p className="text-gray-600 dark:text-gray-300">{error}</p>
|
||||
<div className="text-center max-w-md mx-auto px-4">
|
||||
<div className="text-6xl mb-4">{getErrorIcon(errorCode)}</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-4">
|
||||
{getErrorTitle(errorCode)}
|
||||
</h1>
|
||||
{getErrorDescription(errorCode, publicKey)}
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
|
||||
<div className="text-sm">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
|
||||
>
|
||||
← Back to Mesh Explorer
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -119,9 +279,7 @@ export default function MeshcoreNodePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-neutral-800 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-gray-600 dark:text-gray-300 text-6xl mb-4">❓</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">No Data</h1>
|
||||
<p className="text-gray-600 dark:text-gray-300">No node data available</p>
|
||||
<div className="text-gray-600 dark:text-gray-300">No data available</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -148,28 +306,36 @@ export default function MeshcoreNodePage() {
|
||||
<p className="text-gray-600 dark:text-gray-300 font-mono text-sm">
|
||||
{formatPublicKey(node.public_key)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="flex space-x-2 mb-2">
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{node.is_repeater && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
Repeater
|
||||
</span>
|
||||
)}
|
||||
) || null}
|
||||
{node.is_chat_node && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
Chat Node
|
||||
Companion
|
||||
</span>
|
||||
)}
|
||||
) || null}
|
||||
{node.is_room_server && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
||||
Room Server
|
||||
</span>
|
||||
)}
|
||||
) || null}
|
||||
{!node.is_repeater && !node.is_chat_node && !node.is_room_server && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Unknown
|
||||
</span>
|
||||
) || null}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Last seen: {moment.utc(node.last_seen).format('YYYY-MM-DD HH:mm:ss')} UTC
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<ContactQRCode
|
||||
name={node.has_name ? node.node_name : "Unknown Node"}
|
||||
publicKey={node.public_key}
|
||||
type={getNodeType(node)}
|
||||
size={150}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -182,18 +348,12 @@ export default function MeshcoreNodePage() {
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Public Key</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-100 font-mono break-all">
|
||||
{node.public_key}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Node Name</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{node.has_name ? node.node_name : "Not set"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Current Location</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
@@ -206,20 +366,10 @@ export default function MeshcoreNodePage() {
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Last Seen</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{moment.utc(node.last_seen).format('YYYY-MM-DD HH:mm:ss')} UTC
|
||||
<br />
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{moment.utc(node.last_seen).local().fromNow()}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">MQTT Uplink</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
mqtt.is_uplinked
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
@@ -228,15 +378,30 @@ export default function MeshcoreNodePage() {
|
||||
{mqtt.is_uplinked ? 'Connected' : 'Not Connected'}
|
||||
</span>
|
||||
</div>
|
||||
{mqtt.has_packets && mqtt.last_uplink_time && (
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Last packet: {moment.utc(mqtt.last_uplink_time).format('YYYY-MM-DD HH:mm:ss')} UTC
|
||||
<br />
|
||||
<span className="text-gray-400">
|
||||
{moment.utc(mqtt.last_uplink_time).local().fromNow()}
|
||||
</span>
|
||||
|
||||
{mqtt.topics && mqtt.topics.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium text-gray-700 dark:text-gray-300">Topics:</h4>
|
||||
<div className="space-y-1">
|
||||
{mqtt.topics.map((topic, index) => (
|
||||
<div key={index} className="flex items-center justify-between text-xs bg-gray-50 dark:bg-neutral-800 rounded px-2 py-1">
|
||||
<div className="flex-1">
|
||||
<span className="font-mono text-gray-900 dark:text-gray-100">{topic.topic}</span>
|
||||
<span className="text-gray-500 dark:text-gray-400 ml-2">({topic.broker})</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-gray-900 dark:text-gray-100">
|
||||
{moment.utc(topic.last_packet_time).format('MM-DD HH:mm')}
|
||||
</div>
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
{moment.utc(topic.last_packet_time).local().fromNow()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) || null}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@@ -248,63 +413,18 @@ export default function MeshcoreNodePage() {
|
||||
<div className="bg-white dark:bg-neutral-900 shadow rounded-lg">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-medium text-gray-900 dark:text-gray-100">Recent Adverts</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Latest {recentAdverts.length} adverts with path information</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Latest {recentAdverts.length} adverts</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-neutral-800">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Timestamp</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Path</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Length</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Location</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-neutral-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{recentAdverts.map((advert, index) => (
|
||||
<tr key={index}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{moment.utc(advert.mesh_timestamp).format('MM-DD HH:mm:ss')}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-900 dark:text-gray-100 font-mono">
|
||||
{advert.full_path ? advert.full_path.match(/.{1,2}/g)?.join(' ') || advert.full_path : "-"}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{advert.path_len}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{advert.latitude && advert.longitude ? (
|
||||
<span>
|
||||
{advert.latitude.toFixed(4)}, {advert.longitude.toFixed(4)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<div className="flex space-x-1">
|
||||
{advert.is_repeater && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
R
|
||||
</span>
|
||||
)}
|
||||
{advert.is_chat_node && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
C
|
||||
</span>
|
||||
)}
|
||||
{advert.is_room_server && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
||||
S
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="p-6 space-y-4">
|
||||
{recentAdverts.length === 0 ? (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 py-8">
|
||||
No adverts found
|
||||
</div>
|
||||
) : (
|
||||
recentAdverts.map((advert) => (
|
||||
<AdvertDetails key={advert.group_id} advert={advert} initiatingNodeKey={node.public_key} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -342,6 +462,104 @@ export default function MeshcoreNodePage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Neighbors Section - Only show if MQTT uplink is connected */}
|
||||
{mqtt.is_uplinked && (
|
||||
<div className="mt-6 bg-white dark:bg-neutral-900 shadow rounded-lg">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
Neighbors ({neighborsLoading ? "..." : neighbors.length})
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Nodes heard directly by this node
|
||||
{config.lastSeen !== null && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
Last {(() => {
|
||||
const option = LAST_SEEN_OPTIONS.find(opt => opt.value === config.lastSeen);
|
||||
return option ? option.label : `${Math.floor(config.lastSeen / 3600)}h`;
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
{neighborsLoading ? (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
|
||||
Loading neighbors...
|
||||
</div>
|
||||
) : neighbors.length === 0 ? (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 py-8">
|
||||
No neighbors found
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{neighbors.map((neighbor) => (
|
||||
<div key={neighbor.public_key} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-neutral-800 transition-colors">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{neighbor.has_name ? getNameIconLabel(neighbor.node_name) : "Unknown Node"}
|
||||
</h3>
|
||||
{neighbor.has_name && (
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300 truncate">
|
||||
{neighbor.node_name}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 font-mono truncate">
|
||||
{formatPublicKey(neighbor.public_key)}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href={`/meshcore/node/${neighbor.public_key}`}
|
||||
className="ml-2 text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 text-xs font-medium"
|
||||
>
|
||||
View →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{neighbor.is_repeater && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
Repeater
|
||||
</span>
|
||||
) || null}
|
||||
{neighbor.is_chat_node && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
Companion
|
||||
</span>
|
||||
) || null}
|
||||
{neighbor.is_room_server && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
||||
Room
|
||||
</span>
|
||||
) || null}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 space-y-1">
|
||||
{neighbor.has_location && neighbor.latitude && neighbor.longitude && (
|
||||
<div>
|
||||
Location: {neighbor.latitude.toFixed(4)}, {neighbor.longitude.toFixed(4)}
|
||||
</div>
|
||||
) || null}
|
||||
{neighbor.directions && neighbor.directions.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Direction:</span>
|
||||
{neighbor.directions.includes('incoming') && <span title="Incoming - This node hears the neighbor">📥</span>}
|
||||
{neighbor.directions.includes('outgoing') && <span title="Outgoing - The neighbor hears this node">📤</span>}
|
||||
{neighbor.directions.includes('incoming') && neighbor.directions.includes('outgoing') && (
|
||||
<span className="text-green-600 dark:text-green-400">↔️ Bidirectional</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
164
src/app/search/page.tsx
Normal file
164
src/app/search/page.tsx
Normal file
@@ -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 (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-neutral-900">
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Search MeshCore Nodes
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Find nodes by name or public key across the mesh network
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="mb-6">
|
||||
<SearchInput
|
||||
value={query.q}
|
||||
onChange={setQuery}
|
||||
placeholder="Search by node name or public key..."
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
||||
>
|
||||
<span>Filters</span>
|
||||
<ChevronDownIcon className={`h-4 w-4 transition-transform ${showFilters ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{showFilters && (
|
||||
<div className="mt-4 p-4 bg-white dark:bg-neutral-800 rounded-lg border border-gray-200 dark:border-neutral-700">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Region Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Region
|
||||
</label>
|
||||
<select
|
||||
value={config.selectedRegion || ''}
|
||||
onChange={(e) => handleRegionChange(e.target.value || '')}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Regions</option>
|
||||
<option value="seattle">Seattle</option>
|
||||
<option value="portland">Portland</option>
|
||||
<option value="boston">Boston</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Last Seen Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Last Seen
|
||||
</label>
|
||||
<select
|
||||
value={config.lastSeen || ''}
|
||||
onChange={(e) => handleLastSeenChange(e.target.value ? parseInt(e.target.value, 10) : null)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{LAST_SEEN_OPTIONS.map((option) => (
|
||||
<option key={option.value || 'null'} value={option.value || ''}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Limit Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Results Limit
|
||||
</label>
|
||||
<select
|
||||
value={searchParams.limit}
|
||||
onChange={(e) => handleLimitChange(parseInt(e.target.value, 10))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value={10}>10 results</option>
|
||||
<option value={25}>25 results</option>
|
||||
<option value={50}>50 results</option>
|
||||
<option value={100}>100 results</option>
|
||||
<option value={200}>200 results</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Results */}
|
||||
<SearchResults
|
||||
results={data?.results || []}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
query={query.q}
|
||||
total={data?.total || 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-neutral-900 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading search...</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<SearchPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export default function StatsPage() {
|
||||
const [nodesOverTime, setNodesOverTime] = useState<any[]>([]);
|
||||
const [popularChannels, setPopularChannels] = useState<any[]>([]);
|
||||
const [repeaterPrefixes, setRepeaterPrefixes] = useState<any[]>([]);
|
||||
const [unusedPrefixes, setUnusedPrefixes] = useState<string[]>([]);
|
||||
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() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold mb-2">Unused Repeater Prefixes</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
Public key prefixes that are not currently used by any repeater nodes. Click to generate a key.
|
||||
</p>
|
||||
<div className="grid grid-cols-8 sm:grid-cols-12 md:grid-cols-16 lg:grid-cols-20 gap-1">
|
||||
{unusedPrefixes.map((prefix) => (
|
||||
<a
|
||||
key={prefix}
|
||||
href={`https://gessaman.com/mc-keygen/?prefix=${prefix}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs font-mono text-center p-1 bg-gray-100 dark:bg-gray-800 rounded border hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer"
|
||||
title={`Click to generate a key with prefix ${prefix}`}
|
||||
>
|
||||
{prefix}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Total unused prefixes: {unusedPrefixes.length} out of 254 possible (excluding 00 and FF)
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
168
src/components/AdvertDetails.tsx
Normal file
168
src/components/AdvertDetails.tsx
Normal file
@@ -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 (
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
{/* Header - Always visible */}
|
||||
<div
|
||||
className="px-4 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{moment.utc(advert.earliest_timestamp).format('MM-DD HH:mm:ss')}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{timeRange}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Heard {advert.advert_count} time{advert.advert_count !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex space-x-1">
|
||||
{advert.is_repeater && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
R
|
||||
</span>
|
||||
) || null}
|
||||
{advert.is_chat_node && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
C
|
||||
</span>
|
||||
) || null}
|
||||
{advert.is_room_server && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
||||
S
|
||||
</span>
|
||||
) || null}
|
||||
</div>
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-400 transition-transform ${
|
||||
isExpanded ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable content */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="pt-4 space-y-4">
|
||||
{/* Path details */}
|
||||
<div>
|
||||
<PathVisualization
|
||||
paths={advert.origin_path_pubkey_tuples.map(([origin, path, origin_pubkey], index) => ({
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Location details */}
|
||||
{advert.has_location && advert.latitude && advert.longitude && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
Location
|
||||
</h4>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{advert.latitude.toFixed(6)}, {advert.longitude.toFixed(6)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamp details */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
Timestamps
|
||||
</h4>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300 space-y-1">
|
||||
<div>
|
||||
<span className="font-medium">Earliest:</span> {moment.utc(advert.earliest_timestamp).format('YYYY-MM-DD HH:mm:ss')} UTC
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Latest:</span> {moment.utc(advert.latest_timestamp).format('YYYY-MM-DD HH:mm:ss')} UTC
|
||||
</div>
|
||||
{advert.earliest_timestamp !== advert.latest_timestamp && (
|
||||
<div>
|
||||
<span className="font-medium">Duration:</span> {moment.utc(advert.latest_timestamp).diff(moment.utc(advert.earliest_timestamp), 'seconds')} seconds
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Node capabilities */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
Node Capabilities
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{advert.is_repeater && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
Repeater
|
||||
</span>
|
||||
) || null}
|
||||
{advert.is_chat_node && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
Companion
|
||||
</span>
|
||||
) || null}
|
||||
{advert.is_room_server && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
||||
Room Server
|
||||
</span>
|
||||
) || null}
|
||||
{!advert.is_repeater && !advert.is_chat_node && !advert.is_room_server && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Unknown
|
||||
</span>
|
||||
) || null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, any>;
|
||||
[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<any | null>(null);
|
||||
const [error, setError] = useState<string | null>(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(() => (
|
||||
<div className="text-xs text-gray-600 dark:text-gray-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleOriginsToggle}
|
||||
className="flex items-center gap-1 hover:text-gray-800 dark:hover:text-gray-100 transition-colors"
|
||||
>
|
||||
<span>Heard {originsCount > 0 ? `${originsCount} repeat${originsCount !== 1 ? 's' : ''}` : '0 repeats'}</span>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform ${originsExpanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{originsCount > 0 && (
|
||||
<button
|
||||
onClick={handleGraphToggle}
|
||||
className="flex items-center gap-1 hover:text-gray-800 dark:hover:text-gray-100 transition-colors"
|
||||
>
|
||||
<span>{showGraph ? 'Hide Graph' : 'Show Graph'}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{originsExpanded && originsCount > 0 && (
|
||||
<div className="mt-1 p-2 bg-gray-100 dark:bg-neutral-700 rounded text-xs break-all text-gray-800 dark:text-gray-200">
|
||||
{/* 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 (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<span>{origin}</span>
|
||||
<span className="font-mono">{formattedPath}</span>
|
||||
<span className="text-blue-600 dark:text-blue-400">({pubkeyPrefix})</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
), [originsExpanded, originsCount, showGraph, originKeyPathArray, handleOriginsToggle, handleGraphToggle]);
|
||||
|
||||
const GraphView = useCallback(() => {
|
||||
if (!showGraph || originsCount === 0 || !treeData) return null;
|
||||
|
||||
const renderTree = () => (
|
||||
<Tree
|
||||
key={`tree-${msg.ingest_timestamp}-${originsCount}`}
|
||||
data={treeData}
|
||||
orientation="vertical"
|
||||
pathFunc="step"
|
||||
translate={{ x: graphFullscreen ? 300 : 200, y: graphFullscreen ? 80 : 50 }}
|
||||
separation={{ siblings: 1.2, nonSiblings: 1.5 }}
|
||||
nodeSize={{ x: 60, y: 60 }}
|
||||
zoomable={true}
|
||||
draggable={true}
|
||||
|
||||
renderCustomNodeElement={({ nodeDatum, toggleNode }) => {
|
||||
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 (
|
||||
<g key={`node-${nodeDatum.name}`}>
|
||||
<circle
|
||||
r={15}
|
||||
fill={isRoot ? "#3b82f6" : "#6b7280"}
|
||||
stroke={isOriginPubkey && !isRoot ? "#10b981" : "none"}
|
||||
strokeWidth={isOriginPubkey && !isRoot ? 2 : 0}
|
||||
/>
|
||||
<text
|
||||
textAnchor="middle"
|
||||
y="5"
|
||||
style={{ fontSize: "12px", fill: "white", fontWeight: "bold", stroke: "none" }}
|
||||
>
|
||||
{nodeDatum.name}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (graphFullscreen && typeof window !== 'undefined') {
|
||||
return createPortal(
|
||||
<div
|
||||
key="fullscreen-overlay"
|
||||
className="fixed inset-0 z-50 bg-black bg-opacity-75 flex items-center justify-center"
|
||||
onClick={handleFullscreenToggle}
|
||||
>
|
||||
<div
|
||||
className="bg-gray-100 dark:bg-neutral-700 rounded-lg shadow-2xl w-11/12 h-5/6 p-6 text-gray-800 dark:text-gray-200 flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4 flex-shrink-0">
|
||||
<h2 className="text-lg font-semibold">Message Propagation Tree</h2>
|
||||
<button
|
||||
onClick={handleFullscreenToggle}
|
||||
className="p-2 text-sm rounded bg-gray-200 dark:bg-neutral-700 hover:bg-gray-300 dark:hover:bg-neutral-600 transition-colors"
|
||||
title="Exit Fullscreen"
|
||||
>
|
||||
<ArrowsPointingInIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{renderTree()}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key="graph-container" className="mt-2 p-3 bg-gray-100 dark:bg-neutral-700 rounded text-xs text-gray-800 dark:text-gray-200 relative">
|
||||
<button
|
||||
onClick={handleFullscreenToggle}
|
||||
className="absolute top-2 right-2 p-2 text-xs rounded bg-gray-200 dark:bg-neutral-600 hover:bg-gray-300 dark:hover:bg-neutral-500 transition-colors z-10"
|
||||
title="Enter Fullscreen"
|
||||
>
|
||||
<ArrowsPointingOutIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<div key="graph-content" className="w-full h-80">
|
||||
{renderTree()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [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 && ": "}
|
||||
<span>{linkifyText(parsed.text)}</span>
|
||||
</div>
|
||||
<OriginsBox />
|
||||
<GraphView />
|
||||
<PathVisualization
|
||||
paths={pathData}
|
||||
title={`Heard ${pathData.length} repeat${pathData.length !== 1 ? 's' : ''}`}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -357,8 +139,11 @@ function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessage, showErrorRow
|
||||
<div className="text-xs text-red-600 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
<OriginsBox />
|
||||
<GraphView />
|
||||
<PathVisualization
|
||||
paths={pathData}
|
||||
title={`Heard ${pathData.length} repeat${pathData.length !== 1 ? 's' : ''}`}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
@@ -373,8 +158,11 @@ function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessage, showErrorRow
|
||||
<span className="text-xs text-gray-500 ml-2">channel: {msg.channel_hash}</span>
|
||||
</div>
|
||||
<div className="w-full h-5 bg-gray-200 dark:bg-neutral-800 rounded animate-pulse my-2" />
|
||||
<OriginsBox />
|
||||
<GraphView />
|
||||
<PathVisualization
|
||||
paths={pathData}
|
||||
title={`Heard ${pathData.length} repeat${pathData.length !== 1 ? 's' : ''}`}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
47
src/components/ContactQRCode.tsx
Normal file
47
src/components/ContactQRCode.tsx
Normal file
@@ -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<HTMLCanvasElement>(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 (
|
||||
<div className="flex flex-col items-center">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="border border-gray-200 dark:border-gray-700 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,15 +28,15 @@ export default function Header({ configButtonRef }: HeaderProps) {
|
||||
const itemsRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(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;
|
||||
|
||||
@@ -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<L.Marker | null>(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(<PopupContent node={node} />));
|
||||
|
||||
// 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<any>(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(<PopupContent node={node} />));
|
||||
|
||||
// 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) {
|
||||
<IndividualMarker
|
||||
key={node.node_id}
|
||||
node={node}
|
||||
showNodeNames={showNodeNames}
|
||||
showNodeNames={showNodeNames}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -146,12 +202,77 @@ function ClusteredMarkers({ nodes }: ClusteredMarkersProps) {
|
||||
return (
|
||||
<ClusteredMarkersGroup
|
||||
nodes={nodes}
|
||||
showNodeNames={showNodeNames}
|
||||
showNodeNames={showNodeNames}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<Polyline
|
||||
key={`${selectedNodeId}-${neighbor.public_key}`}
|
||||
positions={positions}
|
||||
pathOptions={{
|
||||
color: lineColor,
|
||||
weight: isBidirectional ? 3 : 2,
|
||||
opacity: 0.7,
|
||||
dashArray: isNeighborVisible ? undefined : '5, 5'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MapView() {
|
||||
const [nodePositions, setNodePositions] = useState<NodePosition[]>([]);
|
||||
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<string | null>(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<TileLayerKey, { url: string; attribution: string; maxZoom: number; subdomains?: string[] }> = {
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
<ClusteredMarkers key={`clustering-${config?.clustering}-${config?.showNodeNames}`} nodes={nodePositions} />
|
||||
<ClusteredMarkers
|
||||
key={`clustering-${config?.clustering}-${config?.showNodeNames}`}
|
||||
nodes={nodePositions}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onNodeClick={handleNodeClick}
|
||||
/>
|
||||
<NeighborLines
|
||||
selectedNodeId={selectedNodeId}
|
||||
neighbors={neighbors}
|
||||
nodes={nodePositions}
|
||||
/>
|
||||
</MapContainer>
|
||||
</div>
|
||||
);
|
||||
|
||||
29
src/components/PathDisplay.tsx
Normal file
29
src/components/PathDisplay.tsx
Normal file
@@ -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 (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
<span className="font-mono text-sm">{formattedPath}</span>
|
||||
<span className="text-blue-600 dark:text-blue-400 text-sm">({pubkeyPrefix})</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
284
src/components/PathVisualization.tsx
Normal file
284
src/components/PathVisualization.tsx
Normal file
@@ -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(() => (
|
||||
<div className="mt-1 p-2 bg-gray-100 dark:bg-neutral-700 rounded text-xs break-all text-gray-800 dark:text-gray-200">
|
||||
{paths.map(({ origin, pubkey, path }, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/meshcore/node/${pubkey}`}
|
||||
className="hover:underline cursor-pointer"
|
||||
>
|
||||
{origin}
|
||||
</Link>
|
||||
<PathDisplay
|
||||
path={path}
|
||||
origin_pubkey={pubkey}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
), [paths]);
|
||||
|
||||
const GraphView = useCallback(() => {
|
||||
if (!showGraph || pathsCount === 0 || !treeData) return null;
|
||||
|
||||
const renderTree = () => (
|
||||
<Tree
|
||||
key={`tree-${pathsCount}`}
|
||||
data={treeData}
|
||||
orientation="vertical"
|
||||
pathFunc="step"
|
||||
translate={{ x: graphFullscreen ? 300 : 200, y: graphFullscreen ? 80 : 50 }}
|
||||
separation={{ siblings: 1.2, nonSiblings: 1.5 }}
|
||||
nodeSize={{ x: 60, y: 60 }}
|
||||
zoomable={true}
|
||||
draggable={true}
|
||||
renderCustomNodeElement={({ nodeDatum, toggleNode }) => {
|
||||
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 (
|
||||
<g key={`node-${nodeDatum.name}`}>
|
||||
<circle
|
||||
r={15}
|
||||
fill={isRoot ? "#3b82f6" : "#6b7280"}
|
||||
stroke={isOriginPubkey && !isRoot ? "#10b981" : "none"}
|
||||
strokeWidth={isOriginPubkey && !isRoot ? 2 : 0}
|
||||
/>
|
||||
<text
|
||||
textAnchor="middle"
|
||||
y="5"
|
||||
style={{ fontSize: "12px", fill: "white", fontWeight: "bold", stroke: "none" }}
|
||||
>
|
||||
{nodeDatum.name}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (graphFullscreen && typeof window !== 'undefined') {
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-lg p-4 max-w-7xl max-h-4xl w-full h-full flex flex-col">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
Path Visualization
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleFullscreenToggle}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 rounded text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-gray-100"
|
||||
>
|
||||
<ArrowsPointingInIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="w-full h-full border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-600">
|
||||
{renderTree()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Path Graph
|
||||
</span>
|
||||
<button
|
||||
onClick={handleFullscreenToggle}
|
||||
className="p-1 hover:bg-gray-100 dark:hover:bg-neutral-800 rounded text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-gray-100"
|
||||
>
|
||||
<ArrowsPointingOutIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-full h-64 border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-600">
|
||||
{renderTree()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [showGraph, pathsCount, treeData, graphFullscreen, handleFullscreenToggle, paths, initiatingNodeKey]);
|
||||
|
||||
if (!showDropdown) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{pathsCount} path{pathsCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{pathsCount > 0 && (
|
||||
<button
|
||||
onClick={handleGraphToggle}
|
||||
className="flex items-center gap-1 hover:text-gray-800 dark:hover:text-gray-100 transition-colors text-sm"
|
||||
>
|
||||
<span>{showGraph ? 'Hide Graph' : 'Show Graph'}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<PathsList />
|
||||
{showGraph && <GraphView />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`text-sm text-gray-600 dark:text-gray-300 ${className}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className="flex items-center gap-1 hover:text-gray-800 dark:hover:text-gray-100 transition-colors"
|
||||
>
|
||||
<span>{pathsCount} path{pathsCount !== 1 ? 's' : ''}</span>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform ${expanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{pathsCount > 0 && (
|
||||
<button
|
||||
onClick={handleGraphToggle}
|
||||
className="flex items-center gap-1 hover:text-gray-800 dark:hover:text-gray-100 transition-colors"
|
||||
>
|
||||
<span>{showGraph ? 'Hide Graph' : 'Show Graph'}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && pathsCount > 0 && (
|
||||
<PathsList />
|
||||
)}
|
||||
|
||||
<GraphView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
src/components/QueryProvider.tsx
Normal file
23
src/components/QueryProvider.tsx
Normal file
@@ -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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
84
src/components/SearchInput.tsx
Normal file
84
src/components/SearchInput.tsx
Normal file
@@ -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<HTMLInputElement>(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 (
|
||||
<div className={`relative ${className}`}>
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={localValue}
|
||||
onChange={(e) => 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 && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
src/components/SearchResults.tsx
Normal file
160
src/components/SearchResults.tsx
Normal file
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="animate-pulse">
|
||||
<div className="bg-white dark:bg-neutral-800 rounded-lg border border-gray-200 dark:border-neutral-700 p-4">
|
||||
<div className="h-4 bg-gray-200 dark:bg-neutral-700 rounded w-3/4 mb-2"></div>
|
||||
<div className="h-3 bg-gray-200 dark:bg-neutral-700 rounded w-1/2 mb-2"></div>
|
||||
<div className="h-3 bg-gray-200 dark:bg-neutral-700 rounded w-1/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-red-500 dark:text-red-400 mb-2">
|
||||
<WifiIcon className="h-12 w-12 mx-auto mb-2" />
|
||||
<p className="text-lg font-medium">Search Error</p>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Failed to search nodes. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!query.trim()) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<MagnifyingGlassIcon className="h-16 w-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
Search MeshCore Nodes
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Enter a node name or public key to search for nodes
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<WifiIcon className="h-16 w-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
No Results Found
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
No nodes found for "{query}". Try a different search term.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
Search Results
|
||||
</h3>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{total} {total === 1 ? 'node' : 'nodes'} found
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{results.map((node) => (
|
||||
<SearchResultItem key={node.public_key} node={node} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Link
|
||||
href={`/meshcore/node/${node.public_key}`}
|
||||
className="block bg-white dark:bg-neutral-800 rounded-lg border border-gray-200 dark:border-neutral-700 p-4 hover:shadow-md dark:hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{node.node_name || 'Unnamed Node'}
|
||||
</h4>
|
||||
<div className="flex items-center gap-1">
|
||||
{isRepeater && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200">
|
||||
<WifiIcon className="h-3 w-3 mr-1" />
|
||||
Repeater
|
||||
</span>
|
||||
)}
|
||||
{isChatNode && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200">
|
||||
<ChatBubbleLeftRightIcon className="h-3 w-3 mr-1" />
|
||||
Chat
|
||||
</span>
|
||||
)}
|
||||
{isRoomServer && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200">
|
||||
<ServerIcon className="h-3 w-3 mr-1" />
|
||||
Room Server
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{hasLocation && node.latitude && node.longitude && (
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPinIcon className="h-4 w-4" />
|
||||
<span>
|
||||
{node.latitude.toFixed(4)}, {node.longitude.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span>Last seen: {moment(lastSeen).fromNow()}</span>
|
||||
<span className="text-xs font-mono text-gray-500 dark:text-gray-500">
|
||||
{node.public_key.substring(0, 8)}...
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 dark:text-gray-500">
|
||||
Topic: {node.topic} • Broker: {node.broker.split('://')[1]}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
79
src/hooks/useMeshcoreSearch.ts
Normal file
79
src/hooks/useMeshcoreSearch.ts
Normal file
@@ -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<MeshcoreSearchResponse> => {
|
||||
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,
|
||||
});
|
||||
}
|
||||
46
src/hooks/useNeighbors.ts
Normal file
46
src/hooks/useNeighbors.ts
Normal file
@@ -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<Neighbor[]> => {
|
||||
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
|
||||
});
|
||||
}
|
||||
124
src/hooks/useSearchQuery.ts
Normal file
124
src/hooks/useSearchQuery.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useMemo, useState, useEffect, useRef } from 'react';
|
||||
|
||||
export function useQueryParams<T extends Record<string, any>>(defaultValues: T = {} as T) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [internalState, setInternalState] = useState<T>(() => {
|
||||
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<T>) => {
|
||||
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(<K extends keyof T>(key: K, value: T[K]) => {
|
||||
updateQuery({ [key]: value } as unknown as Partial<T>);
|
||||
}, [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<SearchQuery>({ q: '', limit: 50 });
|
||||
|
||||
return {
|
||||
query,
|
||||
setQuery: (q: string) => setParam('q', q),
|
||||
setLimit: (limit: number) => setParam('limit', limit),
|
||||
updateQuery: (updates: Partial<SearchQuery>) => {
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
setParam(key as keyof SearchQuery, value as any);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, any> = { 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<string, any> = { 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user