Compare commits
2 Commits
main
...
refactor-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
d2e710d179
|
|||
|
9053ec65a6
|
73
ui/AGENTS.md
73
ui/AGENTS.md
@@ -1,73 +0,0 @@
|
|||||||
# AGENTS
|
|
||||||
|
|
||||||
This document provides context for AI agents working on this codebase.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
HAMView is an online Amateur Radio digital protocol live viewer. It features:
|
|
||||||
- Displaying online radio receivers in near real-time
|
|
||||||
- Streaming of popular Amateur Radio protocols such as APRS, MeshCore, etc.
|
|
||||||
- A live packet stream for each of the protocols
|
|
||||||
- Packet inspection
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
Used technologies:
|
|
||||||
- **Framework**: React 19 with TypeScript
|
|
||||||
- **Build Tool**: Vite 7
|
|
||||||
- **User Interface**: React-Bootstrap with Bootstrap version 5
|
|
||||||
- **Code Editor**: Visual Studio Code
|
|
||||||
- **Backend**: Go with labstack echo router
|
|
||||||
- **Libraries used**: Axios for API requests, mqtt.js for streaming
|
|
||||||
- **Testing**: use `npm run build`
|
|
||||||
|
|
||||||
Relevant documents:
|
|
||||||
- API documentation is in `../server`
|
|
||||||
|
|
||||||
## Testing Requirements
|
|
||||||
|
|
||||||
**Always run tests before completing a task.**
|
|
||||||
|
|
||||||
Run `npm run build` and run `pre-commit run --files changed files...`
|
|
||||||
|
|
||||||
## Coding Guidelines
|
|
||||||
|
|
||||||
### General
|
|
||||||
- Prefer ESM imports (`import`/`export`)
|
|
||||||
- Use builtins from React, React-Boostrap where possible
|
|
||||||
- Follow existing code patterns in the code base
|
|
||||||
- Look for opportunities to create reusable styles in `src/styles` or reusable components in `src/components`
|
|
||||||
- Never make changes outside of the project directory, if you think this is necessary prompt me for approval
|
|
||||||
- Only add things related to the prompted instructions, unless it is required to make the requested changes
|
|
||||||
- When adding imports, apply the import styling rules from the next section.
|
|
||||||
|
|
||||||
### Styling
|
|
||||||
- Use React-Bootstrap components where appropriate
|
|
||||||
- Follow existing CSS patterns
|
|
||||||
- Add reusable style elements to the `src/App.scss`
|
|
||||||
- Explicit imports are better than implicit exports, be as specific as possible to minimize code size
|
|
||||||
- Order imports:
|
|
||||||
- React import first; then any react plugin
|
|
||||||
- Third-party libraries;
|
|
||||||
- Services;
|
|
||||||
- Local types imports;
|
|
||||||
- Local imports;
|
|
||||||
- Stylesheets
|
|
||||||
- Long import statements (> 3 imports) should use multiline import
|
|
||||||
- Sort import imports alphabetically
|
|
||||||
|
|
||||||
## Protected files
|
|
||||||
|
|
||||||
**Never modify files inside the `data/` directory.** This directory contains game data that should remain unchanged.
|
|
||||||
|
|
||||||
Never add secrets to code.
|
|
||||||
|
|
||||||
## Modifying code
|
|
||||||
|
|
||||||
Prefer the patching strategy over running shell commands where possible.
|
|
||||||
Prevent using temporary files and shell commands where possible.
|
|
||||||
|
|
||||||
## Addressing
|
|
||||||
|
|
||||||
Don't call me "the user", refer to me as "the developer".
|
|
||||||
Refrain from using hyperbolic expressions like "excellent" and "perfect", "ok" or "good" is good enough.
|
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/digiview.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>HAMView</title>
|
<title>DigiView [PD0MZ]</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
319
ui/package-lock.json
generated
319
ui/package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/icons-material": "^7.3.8",
|
"@mui/icons-material": "^7.3.8",
|
||||||
|
"@mui/x-charts": "^8.27.4",
|
||||||
"@noble/ciphers": "^2.1.1",
|
"@noble/ciphers": "^2.1.1",
|
||||||
"@noble/curves": "^2.0.1",
|
"@noble/curves": "^2.0.1",
|
||||||
"@noble/ed25519": "^3.0.0",
|
"@noble/ed25519": "^3.0.0",
|
||||||
@@ -1223,6 +1224,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.8.tgz",
|
||||||
"integrity": "sha512-QKd1RhDXE1hf2sQDNayA9ic9jGkEgvZOf0tTkJxlBPG8ns8aS4rS8WwYURw2x5y3739p0HauUXX9WbH7UufFLw==",
|
"integrity": "sha512-QKd1RhDXE1hf2sQDNayA9ic9jGkEgvZOf0tTkJxlBPG8ns8aS4rS8WwYURw2x5y3739p0HauUXX9WbH7UufFLw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.6",
|
"@babel/runtime": "^7.28.6",
|
||||||
"@mui/core-downloads-tracker": "^7.3.8",
|
"@mui/core-downloads-tracker": "^7.3.8",
|
||||||
@@ -1339,6 +1341,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.8.tgz",
|
||||||
"integrity": "sha512-hoFRj4Zw2Km8DPWZp/nKG+ao5Jw5LSk2m/e4EGc6M3RRwXKEkMSG4TgtfVJg7dS2homRwtdXSMW+iRO0ZJ4+IA==",
|
"integrity": "sha512-hoFRj4Zw2Km8DPWZp/nKG+ao5Jw5LSk2m/e4EGc6M3RRwXKEkMSG4TgtfVJg7dS2homRwtdXSMW+iRO0ZJ4+IA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.6",
|
"@babel/runtime": "^7.28.6",
|
||||||
"@mui/private-theming": "^7.3.8",
|
"@mui/private-theming": "^7.3.8",
|
||||||
@@ -1427,6 +1430,105 @@
|
|||||||
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
|
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@mui/x-charts": {
|
||||||
|
"version": "8.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-8.27.4.tgz",
|
||||||
|
"integrity": "sha512-T/vgCoETWiq3ODslAiGogjcqCt8dpjLcdC03l/FROPGHMV9mZ0Fyd9gwmMWTy/UZ+NYiQR/2yqhHEhSPZHQn4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.4",
|
||||||
|
"@mui/utils": "^7.3.5",
|
||||||
|
"@mui/x-charts-vendor": "8.26.0",
|
||||||
|
"@mui/x-internal-gestures": "0.4.0",
|
||||||
|
"@mui/x-internals": "8.26.0",
|
||||||
|
"bezier-easing": "^2.1.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
|
"reselect": "^5.1.1",
|
||||||
|
"use-sync-external-store": "^1.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emotion/react": "^11.9.0",
|
||||||
|
"@emotion/styled": "^11.8.1",
|
||||||
|
"@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0",
|
||||||
|
"@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0",
|
||||||
|
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@emotion/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@emotion/styled": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mui/x-charts-vendor": {
|
||||||
|
"version": "8.26.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-8.26.0.tgz",
|
||||||
|
"integrity": "sha512-R//+WSWvsLJRTjTRN90EKX9sgRzAb4HQBvtUA3cTQpkGrmEjmatD4BJAm3IdRdkSagf6yKWF+ypESctyRhbwnA==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.4",
|
||||||
|
"@types/d3-array": "^3.2.2",
|
||||||
|
"@types/d3-color": "^3.1.3",
|
||||||
|
"@types/d3-format": "^3.0.4",
|
||||||
|
"@types/d3-interpolate": "^3.0.4",
|
||||||
|
"@types/d3-path": "^3.1.1",
|
||||||
|
"@types/d3-scale": "^4.0.9",
|
||||||
|
"@types/d3-shape": "^3.1.7",
|
||||||
|
"@types/d3-time": "^3.0.4",
|
||||||
|
"@types/d3-time-format": "^4.0.3",
|
||||||
|
"@types/d3-timer": "^3.0.2",
|
||||||
|
"d3-array": "^3.2.4",
|
||||||
|
"d3-color": "^3.1.0",
|
||||||
|
"d3-format": "^3.1.0",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-path": "^3.1.0",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.2.0",
|
||||||
|
"d3-time": "^3.1.0",
|
||||||
|
"d3-time-format": "^4.1.0",
|
||||||
|
"d3-timer": "^3.0.1",
|
||||||
|
"flatqueue": "^3.0.0",
|
||||||
|
"internmap": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mui/x-internal-gestures": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mui/x-internal-gestures/-/x-internal-gestures-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-i0W6v9LoiNY8Yf1goOmaygtz/ncPJGBedhpDfvNg/i8BvzPwJcBaeW4rqPucJfVag9KQ8MSssBBrvYeEnrQmhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mui/x-internals": {
|
||||||
|
"version": "8.26.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.26.0.tgz",
|
||||||
|
"integrity": "sha512-B9OZau5IQUvIxwpJZhoFJKqRpmWf5r0yMmSXjQuqb5WuqM755EuzWJOenY48denGoENzMLT8hQpA0hRTeU2IPA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.4",
|
||||||
|
"@mui/utils": "^7.3.5",
|
||||||
|
"reselect": "^5.1.1",
|
||||||
|
"use-sync-external-store": "^1.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/mui-org"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@noble/ciphers": {
|
"node_modules/@noble/ciphers": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
|
||||||
@@ -2319,6 +2421,75 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-format": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time-format": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/deep-eql": {
|
"node_modules/@types/deep-eql": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
@@ -3057,6 +3228,12 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bezier-easing": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/bl": {
|
"node_modules/bl": {
|
||||||
"version": "6.1.6",
|
"version": "6.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz",
|
||||||
@@ -3454,6 +3631,118 @@
|
|||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/date-fns": {
|
"node_modules/date-fns": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
@@ -4018,6 +4307,12 @@
|
|||||||
"node": ">=16"
|
"node": ">=16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/flatqueue": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/flatqueue/-/flatqueue-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-y1deYaVt+lIc/d2uIcWDNd0CrdQTO5xoCjeFdhX0kSXvm2Acm0o+3bAOiYklTEoRyzwio3sv3/IiBZdusbAe2Q==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/flatted": {
|
"node_modules/flatted": {
|
||||||
"version": "3.3.4",
|
"version": "3.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz",
|
||||||
@@ -4349,6 +4644,15 @@
|
|||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/invariant": {
|
"node_modules/invariant": {
|
||||||
"version": "2.2.4",
|
"version": "2.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||||
@@ -5203,6 +5507,12 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -5723,6 +6033,15 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/icons-material": "^7.3.8",
|
"@mui/icons-material": "^7.3.8",
|
||||||
|
"@mui/x-charts": "^8.27.4",
|
||||||
"@noble/ciphers": "^2.1.1",
|
"@noble/ciphers": "^2.1.1",
|
||||||
"@noble/curves": "^2.0.1",
|
"@noble/curves": "^2.0.1",
|
||||||
"@noble/ed25519": "^3.0.0",
|
"@noble/ed25519": "^3.0.0",
|
||||||
|
|||||||
5
ui/public/digiview-icon.svg
Normal file
5
ui/public/digiview-icon.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 24 24" aria-hidden="true" role="img">
|
||||||
|
<rect width="24" height="24" fill="#0b2a5f" />
|
||||||
|
<path d="M20 6H8.3l8.26-3.34L15.88 1 3.24 6.15C2.51 6.43 2 7.17 2 8v12c0 1.1.89 2 2 2h16c1.11 0 2-.9 2-2V8c0-1.11-.89-2-2-2m0 2v3h-2V9h-2v2H4V8zM4 20v-7h16v7z" fill="#FFD54F" />
|
||||||
|
<circle cx="8" cy="16.48" r="2.5" fill="#FFD54F" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 406 B |
4
ui/public/digiview.svg
Normal file
4
ui/public/digiview.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 24 24" aria-hidden="true" role="img">
|
||||||
|
<path d="M20 6H8.3l8.26-3.34L15.88 1 3.24 6.15C2.51 6.43 2 7.17 2 8v12c0 1.1.89 2 2 2h16c1.11 0 2-.9 2-2V8c0-1.11-.89-2-2-2m0 2v3h-2V9h-2v2H4V8zM4 20v-7h16v7z" fill="#FFD54F" />
|
||||||
|
<circle cx="8" cy="16.48" r="2.5" fill="#FFD54F" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 357 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,6 +1,6 @@
|
|||||||
/* Import theme configuration */
|
/* Import theme configuration */
|
||||||
@import './styles/variables';
|
@use './styles/variables' as *;
|
||||||
@import './styles/theme';
|
@use './styles/theme' as *;
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
@@ -24,6 +24,10 @@ body {
|
|||||||
color: var(--app-text);
|
color: var(--app-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-secondary {
|
||||||
|
color: var(--app-blue-light) !important;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--app-accent);
|
color: var(--app-accent);
|
||||||
}
|
}
|
||||||
@@ -33,10 +37,13 @@ a:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.full-view {
|
.full-view {
|
||||||
|
flex: 1 1 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.split-root {
|
.split-root {
|
||||||
@@ -117,6 +124,16 @@ a:hover {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vertical-split-25-75 {
|
||||||
|
.split-pane-primary {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-pane-secondary {
|
||||||
|
flex: 3 1 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* List Group Styles */
|
/* List Group Styles */
|
||||||
.list-item {
|
.list-item {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
|
|||||||
@@ -1,22 +1,31 @@
|
|||||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router'
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router'
|
||||||
|
import { Suspense, lazy } from 'react'
|
||||||
|
import LoadingFallback from './components/LoadingFallback'
|
||||||
|
|
||||||
import { RadiosProvider } from './contexts/RadiosContext'
|
import { RadiosProvider } from './contexts/RadiosContext'
|
||||||
import Overview from './pages/Overview'
|
|
||||||
import APRS from './pages/APRS'
|
|
||||||
import APRSPacketsView from './pages/aprs/APRSPacketsView'
|
|
||||||
import MeshCore from './pages/MeshCore'
|
|
||||||
import MeshCoreGroupChatView from './pages/meshcore/MeshCoreGroupChatView'
|
|
||||||
import MeshCoreMapView from './pages/meshcore/MeshCoreMapView'
|
|
||||||
import MeshCorePacketsView from './pages/meshcore/MeshCorePacketsView'
|
|
||||||
import StyleGuide from './pages/StyleGuide'
|
|
||||||
import NotFound from './pages/NotFound'
|
|
||||||
import { KeyboardNavigationProvider } from './contexts/KeyboardNavigationContext'
|
import { KeyboardNavigationProvider } from './contexts/KeyboardNavigationContext'
|
||||||
import './App.scss'
|
import './App.scss'
|
||||||
|
|
||||||
|
import Overview from './pages/Overview'
|
||||||
|
import APRS from './pages/APRS'
|
||||||
|
import MeshCore from './pages/MeshCore'
|
||||||
|
|
||||||
|
const APRSView = lazy(() => import('./pages/aprs/APRSView'))
|
||||||
|
const APRSPacketsView = lazy(() => import('./pages/aprs/APRSPacketsView'))
|
||||||
|
const ADSB = lazy(() => import('./pages/ADSB'))
|
||||||
|
const ADSBPacketsView = lazy(() => import('./pages/adsb/ADSBPacketsView'))
|
||||||
|
const MeshCoreStatsView = lazy(() => import('./pages/meshcore/MeshCoreStatsView'))
|
||||||
|
const MeshCoreView = lazy(() => import('./pages/meshcore/MeshCoreView'))
|
||||||
|
const MeshCoreGroupChatView = lazy(() => import('./pages/meshcore/MeshCoreGroupChatView'))
|
||||||
|
const MeshCoreMapView = lazy(() => import('./pages/meshcore/MeshCoreMapView'))
|
||||||
|
const MeshCoreNodesView = lazy(() => import('./pages/meshcore/MeshCoreNodesView'))
|
||||||
|
const MeshCorePacketsView = lazy(() => import('./pages/meshcore/MeshCorePacketsView'))
|
||||||
|
const StyleGuide = lazy(() => import('./pages/StyleGuide'))
|
||||||
|
const NotFound = lazy(() => import('./pages/NotFound'))
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ label: 'Radios', to: '/' },
|
{ label: 'Radios', to: '/' },
|
||||||
{ label: 'APRS', to: '/aprs' },
|
{ label: 'APRS', to: '/aprs' },
|
||||||
{ label: 'ADSB', to: '/adsb' },
|
|
||||||
{ label: 'MeshCore', to: '/meshcore' },
|
{ label: 'MeshCore', to: '/meshcore' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -31,21 +40,29 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<KeyboardNavigationProvider>
|
<KeyboardNavigationProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={withRadiosProvider(Overview)()} />
|
<Route path="/" element={withRadiosProvider(Overview)()} />
|
||||||
|
<Route path="/adsb" element={withRadiosProvider(ADSB)()}>
|
||||||
|
<Route index element={<Navigate to="/adsb/packets" replace />} />
|
||||||
|
<Route path="packets" element={<ADSBPacketsView />} />
|
||||||
|
</Route>
|
||||||
<Route path="/aprs" element={withRadiosProvider(APRS)()}>
|
<Route path="/aprs" element={withRadiosProvider(APRS)()}>
|
||||||
<Route index element={<Navigate to="packets" replace />} />
|
<Route index element={<APRSView />} />
|
||||||
<Route path="packets" element={<APRSPacketsView />} />
|
<Route path="packets" element={<APRSPacketsView />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/meshcore" element={withRadiosProvider(MeshCore)()}>
|
<Route path="/meshcore" element={withRadiosProvider(MeshCore)()}>
|
||||||
<Route index element={<Navigate to="packets" replace />} />
|
<Route index element={<MeshCoreView />} />
|
||||||
|
<Route path="stats" element={<MeshCoreStatsView />} />
|
||||||
<Route path="packets" element={<MeshCorePacketsView />} />
|
<Route path="packets" element={<MeshCorePacketsView />} />
|
||||||
<Route path="groupchat" element={<MeshCoreGroupChatView />} />
|
<Route path="groupchat" element={<MeshCoreGroupChatView />} />
|
||||||
|
<Route path="nodes" element={<MeshCoreNodesView />} />
|
||||||
<Route path="map" element={<MeshCoreMapView />} />
|
<Route path="map" element={<MeshCoreMapView />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/style-guide" element={<StyleGuide />} />
|
<Route path="/style-guide" element={<StyleGuide />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</KeyboardNavigationProvider>
|
</KeyboardNavigationProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { FullProps } from '../types/layout.types';
|
|||||||
|
|
||||||
const Full: React.FC<FullProps> = ({ children, className = '' }) => {
|
const Full: React.FC<FullProps> = ({ children, className = '' }) => {
|
||||||
return (
|
return (
|
||||||
<Container fluid className={`full-view p-0 d-flex flex-column ${className}`.trim()}>
|
<Container fluid className={`full-view d-flex flex-column ${className}`.trim()}>
|
||||||
{children}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -92,6 +92,7 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background-color: var(--app-bg);
|
background-color: var(--app-bg);
|
||||||
color: var(--app-text);
|
color: var(--app-text);
|
||||||
|
padding-top: var(--layout-gutter);
|
||||||
padding-left: var(--layout-gutter);
|
padding-left: var(--layout-gutter);
|
||||||
padding-right: var(--layout-gutter);
|
padding-right: var(--layout-gutter);
|
||||||
padding-bottom: var(--layout-gutter);
|
padding-bottom: var(--layout-gutter);
|
||||||
|
|||||||
@@ -4,20 +4,21 @@ import { Link, NavLink, Outlet, useLocation } from 'react-router';
|
|||||||
import KeyboardIcon from '@mui/icons-material/Keyboard';
|
import KeyboardIcon from '@mui/icons-material/Keyboard';
|
||||||
import { useKeyboardNavigationActivity } from '../contexts/KeyboardNavigationContext';
|
import { useKeyboardNavigationActivity } from '../contexts/KeyboardNavigationContext';
|
||||||
import KeyboardShortcutsModal from './KeyboardShortcutsModal';
|
import KeyboardShortcutsModal from './KeyboardShortcutsModal';
|
||||||
|
import Full from './Full';
|
||||||
import { defaultKeyboardShortcuts } from '../hooks/useKeyboardListNavigation';
|
import { defaultKeyboardShortcuts } from '../hooks/useKeyboardListNavigation';
|
||||||
import './Layout.scss';
|
import './Layout.scss';
|
||||||
import type { LayoutProps, NavLinkItem } from '../types/layout.types';
|
import type { LayoutProps, NavLinkItem } from '../types/layout.types';
|
||||||
|
|
||||||
const Layout: React.FC<LayoutProps> = ({
|
const Layout: React.FC<LayoutProps> = ({
|
||||||
children,
|
children,
|
||||||
brandText = 'PD0MZ HAM View',
|
brandText = 'PD0MZ Digi View',
|
||||||
brandTo = '/',
|
brandTo = '/',
|
||||||
buttonGroup,
|
buttonGroup,
|
||||||
navLinks = [],
|
navLinks = [],
|
||||||
gutterSize = 16
|
gutterSize = 16
|
||||||
}) => {
|
}) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { isKeyboardNavigationActive, showGlobalShortcuts, setShowGlobalShortcuts } = useKeyboardNavigationActivity();
|
const { isKeyboardNavigationActive, showGlobalShortcuts, setShowGlobalShortcuts, registeredShortcuts } = useKeyboardNavigationActivity();
|
||||||
|
|
||||||
const isActive = (link: NavLinkItem): boolean => {
|
const isActive = (link: NavLinkItem): boolean => {
|
||||||
return location.pathname.startsWith(link.to);
|
return location.pathname.startsWith(link.to);
|
||||||
@@ -30,7 +31,8 @@ const Layout: React.FC<LayoutProps> = ({
|
|||||||
<Navbar bg="transparent" variant="dark" expand="lg" className="layout-navbar px-4">
|
<Navbar bg="transparent" variant="dark" expand="lg" className="layout-navbar px-4">
|
||||||
<Container fluid>
|
<Container fluid>
|
||||||
<div className="d-flex align-items-center">
|
<div className="d-flex align-items-center">
|
||||||
<Navbar.Brand as={Link} to={brandTo} className="fw-bold brand-text">
|
<Navbar.Brand as={Link} to={brandTo} className="fw-bold d-flex align-items-center brand-text">
|
||||||
|
<img src="/digiview.svg" alt="DigiView" className="brand-image me-2" style={{ width: 28, height: 28 }} />
|
||||||
{brandText}
|
{brandText}
|
||||||
</Navbar.Brand>
|
</Navbar.Brand>
|
||||||
{buttonGroup && <div className="ms-3">{buttonGroup}</div>}
|
{buttonGroup && <div className="ms-3">{buttonGroup}</div>}
|
||||||
@@ -72,15 +74,28 @@ const Layout: React.FC<LayoutProps> = ({
|
|||||||
</Container>
|
</Container>
|
||||||
</Navbar>
|
</Navbar>
|
||||||
|
|
||||||
<Container fluid className="main-content d-flex flex-column" style={{ marginTop: resolvedGutter }}>
|
<Full className="main-content">
|
||||||
{children || <Outlet />}
|
{children || <Outlet />}
|
||||||
</Container>
|
</Full>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const merged = [...registeredShortcuts, ...defaultKeyboardShortcuts];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const unique = merged.filter((s) => {
|
||||||
|
const key = `${s.keys}||${s.description}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
<KeyboardShortcutsModal
|
<KeyboardShortcutsModal
|
||||||
show={showGlobalShortcuts}
|
show={showGlobalShortcuts}
|
||||||
onHide={() => setShowGlobalShortcuts(false)}
|
onHide={() => setShowGlobalShortcuts(false)}
|
||||||
shortcuts={defaultKeyboardShortcuts}
|
shortcuts={unique}
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
<footer className="layout-footer">
|
<footer className="layout-footer">
|
||||||
<p>© {new Date().getFullYear()} PD0MZ. All rights reserved.</p>
|
<p>© {new Date().getFullYear()} PD0MZ. All rights reserved.</p>
|
||||||
|
|||||||
60
ui/src/components/LoadingFallback.scss
Normal file
60
ui/src/components/LoadingFallback.scss
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
@use './Layout' as *;
|
||||||
|
|
||||||
|
.loading-fallback {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 1.25rem;
|
||||||
|
z-index: 9999;
|
||||||
|
background: linear-gradient(120deg, rgba(250,250,252,0.45), rgba(240,243,255,0.45));
|
||||||
|
background-size: 200% 200%;
|
||||||
|
animation: gradientShift 8s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-fallback .loading-content {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: rgba(255,255,255,0.7);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(15,15,15,0.08);
|
||||||
|
color: #1f1f1f;
|
||||||
|
margin: auto 0; /* center vertically within the column layout */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure footer spans the full viewport width inside the loading overlay */
|
||||||
|
.loading-fallback.layout-container .layout-footer {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-fallback .loading-content .spinner-border {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border-width: 0.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradientShift {
|
||||||
|
0% { background-position: 0% 50%; }
|
||||||
|
50% { background-position: 100% 50%; }
|
||||||
|
100% { background-position: 0% 50%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.loading-fallback {
|
||||||
|
background: linear-gradient(120deg, rgba(16,18,22,0.5), rgba(8,10,14,0.45));
|
||||||
|
}
|
||||||
|
.loading-fallback .loading-content {
|
||||||
|
background: rgba(16,18,22,0.6);
|
||||||
|
color: #e6e6e6;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
ui/src/components/LoadingFallback.tsx
Normal file
16
ui/src/components/LoadingFallback.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import '../styles/_theme.scss'
|
||||||
|
import './LoadingFallback.scss'
|
||||||
|
|
||||||
|
export default function LoadingFallback() {
|
||||||
|
return (
|
||||||
|
<div className="layout-container loading-fallback">
|
||||||
|
<div className="loading-content">
|
||||||
|
<strong>Loading…</strong>
|
||||||
|
<div className="spinner-border ms-3" role="status" aria-hidden />
|
||||||
|
</div>
|
||||||
|
<footer className="layout-footer">
|
||||||
|
<p>© {new Date().getFullYear()} PD0MZ. All rights reserved.</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -327,6 +327,7 @@ const PacketDissectionViewer: React.FC<PacketDissectionViewerProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// console.log(`Rendering PacketDissectionViewer with ${bytes.length} bytes and ${segments.length} segments`);
|
||||||
return (
|
return (
|
||||||
<div className="packet-dissection-viewer">
|
<div className="packet-dissection-viewer">
|
||||||
<h6 className="packet-dissection-title">{title}</h6>
|
<h6 className="packet-dissection-title">{title}</h6>
|
||||||
|
|||||||
46
ui/src/components/SignalGrade.scss
Normal file
46
ui/src/components/SignalGrade.scss
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
.signal-grade {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.08rem 0.4rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
font-weight: 100;
|
||||||
|
font-size: 0.75rem; /* very small */
|
||||||
|
min-width: 2.4rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--app-text);
|
||||||
|
background: transparent;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
text-transform: uppercase; /* make displayed value all caps */
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-grade--excellent {
|
||||||
|
border-color: #15803d; /* darker green */
|
||||||
|
background: rgba(21, 128, 61, 0.14);
|
||||||
|
color: #e8fff0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-grade--good {
|
||||||
|
border-color: #2563eb; /* darker blue */
|
||||||
|
background: rgba(37, 99, 235, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-grade--fair {
|
||||||
|
border-color: #b45309; /* darker amber */
|
||||||
|
background: rgba(180, 83, 9, 0.12);
|
||||||
|
color: #fff8ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-grade--poor {
|
||||||
|
border-color: #d97706;
|
||||||
|
background: rgba(217, 119, 6, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-grade--critical {
|
||||||
|
border-color: #b91c1c; /* darker red */
|
||||||
|
background: rgba(185, 28, 28, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-grade--unknown {
|
||||||
|
border-color: rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
}
|
||||||
47
ui/src/components/SignalGrade.tsx
Normal file
47
ui/src/components/SignalGrade.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import './SignalGrade.scss';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
snr?: number | null;
|
||||||
|
rssi?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const gradeFromSNR = (snr: number) => {
|
||||||
|
const m = snr - -7.5;
|
||||||
|
return m >= 6 ? 'excellent' : m >= 3 ? 'good' : m >= 0 ? 'fair' : m >= -3 ? 'poor' : 'critical';
|
||||||
|
};
|
||||||
|
|
||||||
|
const gradeFromRSSI = (rssi: number) => {
|
||||||
|
const m = rssi - -124;
|
||||||
|
return m >= 10 ? 'excellent' : m >= 6 ? 'good' : m >= 2 ? 'fair' : m >= -1 ? 'poor' : 'critical';
|
||||||
|
};
|
||||||
|
|
||||||
|
const worst = (a: string, b: string) => {
|
||||||
|
const order = ['excellent', 'good', 'fair', 'poor', 'critical'];
|
||||||
|
return order[Math.max(order.indexOf(a), order.indexOf(b))];
|
||||||
|
};
|
||||||
|
|
||||||
|
const SignalGrade: React.FC<Props> = ({ snr, rssi }) => {
|
||||||
|
let grade: string | undefined;
|
||||||
|
|
||||||
|
if (snr !== undefined && snr !== null) {
|
||||||
|
grade = gradeFromSNR(snr);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rssi !== undefined && rssi !== null) {
|
||||||
|
const rg = gradeFromRSSI(rssi);
|
||||||
|
grade = grade ? worst(grade, rg) : rg;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`signal-grade signal-grade--${grade ?? 'unknown'}`}
|
||||||
|
title={grade ?? 'unknown'}
|
||||||
|
aria-label={grade ?? 'unknown'}
|
||||||
|
>
|
||||||
|
{grade ? grade : '-'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SignalGrade;
|
||||||
64
ui/src/components/TimeAgo.test.tsx
Normal file
64
ui/src/components/TimeAgo.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen, cleanup } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import TimeAgo from './TimeAgo';
|
||||||
|
|
||||||
|
describe('TimeAgo', () => {
|
||||||
|
const now = Date.UTC(2026, 2, 8, 12, 0, 0); // 2026-03-08T12:00:00Z
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(now);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders seconds ago', () => {
|
||||||
|
const t = new Date(now - 10 * 1000).toISOString();
|
||||||
|
render(<TimeAgo time={t} />);
|
||||||
|
expect(screen.getByText('10s ago')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders minutes ago', () => {
|
||||||
|
const t = new Date(now - 5 * 60 * 1000).toISOString();
|
||||||
|
render(<TimeAgo time={t} />);
|
||||||
|
expect(screen.getByText('5m ago')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders hours ago', () => {
|
||||||
|
const t = new Date(now - 2 * 60 * 60 * 1000).toISOString();
|
||||||
|
render(<TimeAgo time={t} />);
|
||||||
|
expect(screen.getByText('2h ago')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders days ago', () => {
|
||||||
|
const t = new Date(now - 4 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
render(<TimeAgo time={t} />);
|
||||||
|
expect(screen.getByText('4d ago')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders weeks ago', () => {
|
||||||
|
const t = new Date(now - 14 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
render(<TimeAgo time={t} />);
|
||||||
|
expect(screen.getByText('2w ago')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders months ago', () => {
|
||||||
|
const t = new Date(now - 40 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
render(<TimeAgo time={t} />);
|
||||||
|
expect(screen.getByText('1mo ago')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders dash for null/undefined', () => {
|
||||||
|
render(<TimeAgo time={null} />);
|
||||||
|
expect(screen.getByText('-')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders dash for invalid time', () => {
|
||||||
|
render(<TimeAgo time={("not-a-time" as unknown) as Date} />);
|
||||||
|
expect(screen.getByText('-')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
72
ui/src/components/TimeAgo.tsx
Normal file
72
ui/src/components/TimeAgo.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
import { subscribe, getSnapshot } from './TimeTicker';
|
||||||
|
|
||||||
|
interface TimeAgoProps {
|
||||||
|
time?: string | null | Date;
|
||||||
|
format?: 'short' | 'long';
|
||||||
|
showAgo?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a compact label for a time difference (UTC millisecond inputs).
|
||||||
|
function formatRelative(nowMs: number, timeMs: number, fmt: 'short' | 'long' = 'short', showAgo = true): string {
|
||||||
|
const diffSec = Math.max(0, Math.floor((nowMs - timeMs) / 1000));
|
||||||
|
|
||||||
|
const units = [
|
||||||
|
{ threshold: 60, divisor: 1, short: 's', long: 'second' },
|
||||||
|
{ threshold: 60 * 60, divisor: 60, short: 'm', long: 'minute' },
|
||||||
|
{ threshold: 60 * 60 * 24, divisor: 60 * 60, short: 'h', long: 'hour' },
|
||||||
|
{ threshold: 60 * 60 * 24 * 7, divisor: 60 * 60 * 24, short: 'd', long: 'day' },
|
||||||
|
{ threshold: 60 * 60 * 24 * 30, divisor: 60 * 60 * 24 * 7, short: 'w', long: 'week' },
|
||||||
|
// months: fallback bucket
|
||||||
|
{ threshold: Infinity, divisor: 60 * 60 * 24 * 30, short: 'mo', long: 'month' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const plural = (n: number, singular: string) => (n === 1 ? `${n} ${singular}` : `${n} ${singular}s`);
|
||||||
|
|
||||||
|
// find first matching unit
|
||||||
|
for (const u of units) {
|
||||||
|
if (diffSec < u.threshold) {
|
||||||
|
if (fmt === 'short') {
|
||||||
|
if (u.short === 'mo') {
|
||||||
|
// for months, compute from days to ensure at least 1 month
|
||||||
|
const months = Math.max(1, Math.floor(diffSec / u.divisor));
|
||||||
|
return `${months}${u.short}${showAgo ? ' ago' : ''}`;
|
||||||
|
}
|
||||||
|
const value = Math.floor(diffSec / u.divisor);
|
||||||
|
return `${value}${u.short}${showAgo ? ' ago' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// long format
|
||||||
|
if (diffSec < 1) return 'just now';
|
||||||
|
if (u.long === 'month') {
|
||||||
|
const months = Math.max(1, Math.floor(diffSec / u.divisor));
|
||||||
|
return `${plural(months, u.long)}${showAgo ? ' ago' : ''}`;
|
||||||
|
}
|
||||||
|
const value = Math.floor(diffSec / u.divisor);
|
||||||
|
return `${plural(value, u.long)}${showAgo ? ' ago' : ''}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'just now';
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimeAgo subscribes to a single shared ticker (via useSyncExternalStore) so
|
||||||
|
// many instances update efficiently without per-component timers.
|
||||||
|
const TimeAgo: React.FC<TimeAgoProps> = ({ time, format = 'short', showAgo = true }) => {
|
||||||
|
if (!time) return <>-</>;
|
||||||
|
|
||||||
|
const now = useSyncExternalStore(subscribe, getSnapshot);
|
||||||
|
|
||||||
|
const timeMs = typeof time === 'string'
|
||||||
|
? Date.parse(time)
|
||||||
|
: (time instanceof Date ? time.getTime() : NaN);
|
||||||
|
|
||||||
|
if (!timeMs || Number.isNaN(timeMs)) return <>-</>;
|
||||||
|
|
||||||
|
const label = formatRelative(now, timeMs, format, showAgo);
|
||||||
|
|
||||||
|
return <span title={new Date(timeMs).toLocaleString()}>{label}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TimeAgo;
|
||||||
24
ui/src/components/TimeTicker.ts
Normal file
24
ui/src/components/TimeTicker.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
let listeners = new Set<() => void>();
|
||||||
|
let timerId: number | null = null;
|
||||||
|
|
||||||
|
export const getSnapshot = (): number => Date.now();
|
||||||
|
|
||||||
|
export const subscribe = (cb: () => void) => {
|
||||||
|
listeners.add(cb);
|
||||||
|
// start shared timer when first subscriber appears
|
||||||
|
if (timerId === null) {
|
||||||
|
timerId = window.setInterval(() => {
|
||||||
|
for (const l of Array.from(listeners)) l();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
listeners.delete(cb);
|
||||||
|
if (listeners.size === 0 && timerId !== null) {
|
||||||
|
window.clearInterval(timerId);
|
||||||
|
timerId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default { subscribe, getSnapshot };
|
||||||
@@ -11,6 +11,7 @@ const VerticalSplit: React.FC<VerticalSplitProps> = ({
|
|||||||
const ratioClass =
|
const ratioClass =
|
||||||
ratio === '3:1' ? 'vertical-split-3-1' :
|
ratio === '3:1' ? 'vertical-split-3-1' :
|
||||||
ratio === '2:1' ? 'vertical-split-2-1' :
|
ratio === '2:1' ? 'vertical-split-2-1' :
|
||||||
|
ratio === '25:75' ? 'vertical-split-25-75' :
|
||||||
ratio === '25/70' ? 'vertical-split-25-70' :
|
ratio === '25/70' ? 'vertical-split-25-70' :
|
||||||
'vertical-split-1-1';
|
'vertical-split-1-1';
|
||||||
|
|
||||||
|
|||||||
22
ui/src/components/protocol/KPICard.tsx
Normal file
22
ui/src/components/protocol/KPICard.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import React, { type ReactNode } from 'react';
|
||||||
|
import { Card } from 'react-bootstrap';
|
||||||
|
|
||||||
|
import '../../styles/ProtocolBriefing.scss';
|
||||||
|
|
||||||
|
export interface KPICardProps {
|
||||||
|
label: string;
|
||||||
|
value: ReactNode;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KPICard: React.FC<KPICardProps> = ({ label, value, description }) => {
|
||||||
|
return (
|
||||||
|
<Card className="data-table-card">
|
||||||
|
<Card.Body className="protocol-briefing-kpi-card">
|
||||||
|
<div className="protocol-briefing-kpi-label">{label}</div>
|
||||||
|
<div className="protocol-briefing-kpi-value">{value}</div>
|
||||||
|
<div className="text-secondary small">{description}</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
53
ui/src/components/protocol/ProtocolHero.tsx
Normal file
53
ui/src/components/protocol/ProtocolHero.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import React, { type ReactNode } from 'react';
|
||||||
|
import { Badge, Card } from 'react-bootstrap';
|
||||||
|
|
||||||
|
import '../../styles/ProtocolBriefing.scss';
|
||||||
|
|
||||||
|
export interface ProtocolHeroProps {
|
||||||
|
protocolBadges: Array<{ label: string; variant: string }>;
|
||||||
|
title: string;
|
||||||
|
description: ReactNode;
|
||||||
|
logoSrc: string;
|
||||||
|
logoAlt: string;
|
||||||
|
logoLabel: string;
|
||||||
|
statusPills?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProtocolHero: React.FC<ProtocolHeroProps> = ({
|
||||||
|
protocolBadges,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
logoSrc,
|
||||||
|
logoAlt,
|
||||||
|
logoLabel,
|
||||||
|
statusPills,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Card className="data-table-card protocol-briefing-hero">
|
||||||
|
<Card.Body>
|
||||||
|
<div className="protocol-briefing-hero-layout">
|
||||||
|
<div className="protocol-briefing-hero-content">
|
||||||
|
<div className="d-flex flex-wrap align-items-center gap-2 mb-3">
|
||||||
|
{protocolBadges.map((badge, index) => (
|
||||||
|
<Badge key={index} bg={badge.variant}>
|
||||||
|
{badge.label}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="mb-2">{title}</h2>
|
||||||
|
|
||||||
|
<div className="mb-0 text-secondary">{description}</div>
|
||||||
|
|
||||||
|
{statusPills ? <div className="protocol-briefing-signal-row">{statusPills}</div> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="protocol-briefing-hero-logo-wrap" aria-hidden="true">
|
||||||
|
<img src={logoSrc} alt={logoAlt} className="protocol-briefing-hero-logo" />
|
||||||
|
<div className="protocol-briefing-hero-logo-label">{logoLabel}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
64
ui/src/components/protocol/RadioListItem.tsx
Normal file
64
ui/src/components/protocol/RadioListItem.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Badge } from 'react-bootstrap';
|
||||||
|
|
||||||
|
import { getDeviceImageURL } from '../../libs/deviceImageMapper';
|
||||||
|
import type { Radio } from '../../types/radio.types';
|
||||||
|
|
||||||
|
import '../../styles/ProtocolBriefing.scss';
|
||||||
|
|
||||||
|
export interface RadioListItemProps {
|
||||||
|
radio: Radio;
|
||||||
|
onRadioClick: (radioName: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RadioListItem: React.FC<RadioListItemProps> = ({ radio, onRadioClick }) => {
|
||||||
|
const handleClick = () => {
|
||||||
|
onRadioClick(radio.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
onRadioClick(radio.name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="protocol-briefing-radio-item protocol-briefing-radio-item--clickable"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<div className="protocol-briefing-radio-main">
|
||||||
|
<img
|
||||||
|
src={getDeviceImageURL(radio.protocol, radio.manufacturer, radio.device)}
|
||||||
|
alt={`${radio.manufacturer || 'Unknown'} ${radio.device || ''}`}
|
||||||
|
className="protocol-briefing-radio-image"
|
||||||
|
onError={(event) => {
|
||||||
|
(event.target as HTMLImageElement).src = '/image/device/unknown.png';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="protocol-briefing-radio-title">
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`protocol-briefing-radio-dot ${radio.is_online ? 'is-online' : 'is-offline'}`}
|
||||||
|
/>
|
||||||
|
<strong>{radio.name}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="protocol-briefing-radio-meta text-secondary">
|
||||||
|
{radio.frequency.toFixed(3)} MHz · {radio.bandwidth.toFixed(1)} kHz
|
||||||
|
{radio.lora_sf !== undefined ? ` · SF${radio.lora_sf}` : ''}
|
||||||
|
{radio.lora_cr !== undefined ? ` · CR${radio.lora_cr}` : ''}
|
||||||
|
{radio.manufacturer ? ` · ${radio.manufacturer}` : ''}
|
||||||
|
{radio.device && !radio.manufacturer ? ` · ${radio.device}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge bg={radio.is_online ? 'success' : 'secondary'}>
|
||||||
|
{radio.is_online ? 'Online' : 'Offline'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
19
ui/src/components/protocol/StatusPill.tsx
Normal file
19
ui/src/components/protocol/StatusPill.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Badge } from 'react-bootstrap';
|
||||||
|
|
||||||
|
import '../../styles/ProtocolBriefing.scss';
|
||||||
|
|
||||||
|
export interface StatusPillProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone: 'success' | 'warning' | 'danger' | 'secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatusPill: React.FC<StatusPillProps> = ({ label, value, tone }) => {
|
||||||
|
return (
|
||||||
|
<div className="protocol-briefing-signal-pill">
|
||||||
|
<span>{label}</span>
|
||||||
|
<Badge bg={tone}>{value}</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import type { KeyboardShortcut } from '../hooks/useKeyboardListNavigation';
|
||||||
|
|
||||||
type KeyboardNavigationContextValue = {
|
type KeyboardNavigationContextValue = {
|
||||||
isKeyboardNavigationActive: boolean;
|
isKeyboardNavigationActive: boolean;
|
||||||
@@ -6,6 +7,9 @@ type KeyboardNavigationContextValue = {
|
|||||||
deactivateKeyboardNavigation: () => void;
|
deactivateKeyboardNavigation: () => void;
|
||||||
showGlobalShortcuts: boolean;
|
showGlobalShortcuts: boolean;
|
||||||
setShowGlobalShortcuts: (show: boolean) => void;
|
setShowGlobalShortcuts: (show: boolean) => void;
|
||||||
|
registerShortcuts: (id: string, shortcuts: KeyboardShortcut[]) => void;
|
||||||
|
unregisterShortcuts: (id: string) => void;
|
||||||
|
registeredShortcuts: KeyboardShortcut[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const KeyboardNavigationContext = React.createContext<KeyboardNavigationContextValue | undefined>(undefined);
|
const KeyboardNavigationContext = React.createContext<KeyboardNavigationContextValue | undefined>(undefined);
|
||||||
@@ -13,6 +17,19 @@ const KeyboardNavigationContext = React.createContext<KeyboardNavigationContextV
|
|||||||
export const KeyboardNavigationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const KeyboardNavigationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const [activeCount, setActiveCount] = React.useState(0);
|
const [activeCount, setActiveCount] = React.useState(0);
|
||||||
const [showGlobalShortcuts, setShowGlobalShortcuts] = React.useState(false);
|
const [showGlobalShortcuts, setShowGlobalShortcuts] = React.useState(false);
|
||||||
|
const [registeredMap, setRegisteredMap] = React.useState<Record<string, KeyboardShortcut[]>>({});
|
||||||
|
|
||||||
|
const registerShortcuts = React.useCallback((id: string, shortcuts: KeyboardShortcut[]) => {
|
||||||
|
setRegisteredMap((m) => ({ ...m, [id]: shortcuts }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const unregisterShortcuts = React.useCallback((id: string) => {
|
||||||
|
setRegisteredMap((m) => {
|
||||||
|
const n = { ...m };
|
||||||
|
delete n[id];
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const activateKeyboardNavigation = React.useCallback(() => {
|
const activateKeyboardNavigation = React.useCallback(() => {
|
||||||
setActiveCount((count) => count + 1);
|
setActiveCount((count) => count + 1);
|
||||||
@@ -28,7 +45,10 @@ export const KeyboardNavigationProvider: React.FC<{ children: React.ReactNode }>
|
|||||||
deactivateKeyboardNavigation,
|
deactivateKeyboardNavigation,
|
||||||
showGlobalShortcuts,
|
showGlobalShortcuts,
|
||||||
setShowGlobalShortcuts,
|
setShowGlobalShortcuts,
|
||||||
}), [activeCount, activateKeyboardNavigation, deactivateKeyboardNavigation, showGlobalShortcuts]);
|
registerShortcuts,
|
||||||
|
unregisterShortcuts,
|
||||||
|
registeredShortcuts: Object.values(registeredMap).flat(),
|
||||||
|
}), [activeCount, activateKeyboardNavigation, deactivateKeyboardNavigation, showGlobalShortcuts, registerShortcuts, unregisterShortcuts, registeredMap]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardNavigationContext.Provider value={value}>
|
<KeyboardNavigationContext.Provider value={value}>
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ type UseKeyboardListNavigationOptions = {
|
|||||||
scrollContainerRef: React.RefObject<HTMLElement | null>;
|
scrollContainerRef: React.RefObject<HTMLElement | null>;
|
||||||
rowSelector?: string;
|
rowSelector?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
onPrevPage?: () => void;
|
||||||
|
onNextPage?: () => void;
|
||||||
shortcuts?: KeyboardShortcut[];
|
shortcuts?: KeyboardShortcut[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,10 +42,11 @@ export const useKeyboardListNavigation = ({
|
|||||||
scrollContainerRef,
|
scrollContainerRef,
|
||||||
rowSelector = '[data-nav-item="true"]',
|
rowSelector = '[data-nav-item="true"]',
|
||||||
enabled = true,
|
enabled = true,
|
||||||
|
onPrevPage,
|
||||||
|
onNextPage,
|
||||||
shortcuts = DEFAULT_SHORTCUTS,
|
shortcuts = DEFAULT_SHORTCUTS,
|
||||||
}: UseKeyboardListNavigationOptions) => {
|
}: UseKeyboardListNavigationOptions) => {
|
||||||
const [showShortcuts, setShowShortcuts] = React.useState(false);
|
const { activateKeyboardNavigation, deactivateKeyboardNavigation, showGlobalShortcuts, setShowGlobalShortcuts, registerShortcuts, unregisterShortcuts } = useKeyboardNavigationActivity();
|
||||||
const { activateKeyboardNavigation, deactivateKeyboardNavigation } = useKeyboardNavigationActivity();
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
@@ -96,7 +99,7 @@ export const useKeyboardListNavigation = ({
|
|||||||
const isQuestionMark = event.key === '?' || (event.key === '/' && event.shiftKey);
|
const isQuestionMark = event.key === '?' || (event.key === '/' && event.shiftKey);
|
||||||
if (event.key === 'F1' || isQuestionMark) {
|
if (event.key === 'F1' || isQuestionMark) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setShowShortcuts(true);
|
if (typeof setShowGlobalShortcuts === 'function') setShowGlobalShortcuts(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,17 +162,57 @@ export const useKeyboardListNavigation = ({
|
|||||||
if (event.key === 'End') {
|
if (event.key === 'End') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
selectAndScroll(itemCount - 1);
|
selectAndScroll(itemCount - 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// allow page navigation if provided
|
||||||
|
if (event.key === 'ArrowLeft') {
|
||||||
|
if (typeof onPrevPage === 'function') {
|
||||||
|
event.preventDefault();
|
||||||
|
onPrevPage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowRight') {
|
||||||
|
if (typeof onNextPage === 'function') {
|
||||||
|
event.preventDefault();
|
||||||
|
onNextPage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [enabled, getPageStep, itemCount, onSelectIndex, scrollToIndex, selectedIndex]);
|
}, [enabled, getPageStep, itemCount, onSelectIndex, scrollToIndex, selectedIndex, onPrevPage, onNextPage, setShowGlobalShortcuts]);
|
||||||
|
|
||||||
|
const finalShortcuts = React.useMemo(() => {
|
||||||
|
const list = Array.isArray(shortcuts) ? [...shortcuts] : [];
|
||||||
|
const hasPrev = typeof onPrevPage === 'function';
|
||||||
|
const hasNext = typeof onNextPage === 'function';
|
||||||
|
if (hasPrev && hasNext) {
|
||||||
|
list.unshift({ keys: '← / →', description: 'Previous or next page' });
|
||||||
|
} else if (hasPrev) {
|
||||||
|
list.unshift({ keys: '←', description: 'Previous page' });
|
||||||
|
} else if (hasNext) {
|
||||||
|
list.unshift({ keys: '→', description: 'Next page' });
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, [shortcuts, onPrevPage, onNextPage]);
|
||||||
|
|
||||||
|
// register finalShortcuts with global context so the modal shows them
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (typeof registerShortcuts !== 'function' || typeof unregisterShortcuts !== 'function') return;
|
||||||
|
const id = `kbd-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
registerShortcuts(id, finalShortcuts);
|
||||||
|
return () => unregisterShortcuts(id);
|
||||||
|
}, [finalShortcuts, registerShortcuts, unregisterShortcuts]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
showShortcuts,
|
showShortcuts: showGlobalShortcuts,
|
||||||
setShowShortcuts,
|
setShowShortcuts: setShowGlobalShortcuts,
|
||||||
shortcuts,
|
shortcuts: finalShortcuts,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
13
ui/src/pages/ADSB.scss
Normal file
13
ui/src/pages/ADSB.scss
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
.adsb-view-switch {
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adsb-packets-view {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adsb-marker {
|
||||||
|
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
|
||||||
|
}
|
||||||
38
ui/src/pages/ADSB.tsx
Normal file
38
ui/src/pages/ADSB.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ButtonGroup } from 'react-bootstrap';
|
||||||
|
import FlightIcon from '@mui/icons-material/Flight';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import { NavLink, Outlet, useLocation } from 'react-router';
|
||||||
|
import { ADSBDataProvider } from './adsb/ADSBData';
|
||||||
|
import type { NavLinkItem } from '../types/layout.types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
navLinks?: NavLinkItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADSB: React.FC<Props> = ({ navLinks = [] }) => {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const viewButtons = (
|
||||||
|
<ButtonGroup className="adsb-view-switch" size="sm" aria-label="ADSB view switch">
|
||||||
|
<NavLink
|
||||||
|
to="/adsb/packets"
|
||||||
|
className={`btn btn-sm btn-icon ${location.pathname.startsWith('/adsb/packets') ? 'btn-primary' : 'btn-outline-light'}`}
|
||||||
|
title="Packets"
|
||||||
|
>
|
||||||
|
<FlightIcon className="icon" />
|
||||||
|
<span className="ms-1 d-none d-lg-inline">Packets</span>
|
||||||
|
</NavLink>
|
||||||
|
</ButtonGroup>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ADSBDataProvider>
|
||||||
|
<Layout navLinks={navLinks} buttonGroup={viewButtons}>
|
||||||
|
<Outlet />
|
||||||
|
</Layout>
|
||||||
|
</ADSBDataProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ADSB;
|
||||||
@@ -136,3 +136,6 @@
|
|||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// APRS-specific styles (briefing styles moved to shared ProtocolBriefing.scss)
|
||||||
|
// Add any APRS-specific styles here if needed
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ const APRS: React.FC<Props> = ({ navLinks = [] }) => {
|
|||||||
|
|
||||||
const viewButtons = (
|
const viewButtons = (
|
||||||
<ButtonGroup className="aprs-view-switch" size="sm" aria-label="APRS view switch">
|
<ButtonGroup className="aprs-view-switch" size="sm" aria-label="APRS view switch">
|
||||||
|
<NavLink
|
||||||
|
to="/aprs"
|
||||||
|
end
|
||||||
|
className={`btn d-none d-lg-inline-block ${(location.pathname === '/aprs' || location.pathname.startsWith('/aprs/briefing')) ? 'btn-primary' : 'btn-outline-light'}`}
|
||||||
|
>
|
||||||
|
Briefing
|
||||||
|
</NavLink>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/aprs/packets"
|
to="/aprs/packets"
|
||||||
className={`btn d-none d-lg-inline-block ${location.pathname.startsWith('/aprs/packets') ? 'btn-primary' : 'btn-outline-light'}`}
|
className={`btn d-none d-lg-inline-block ${location.pathname.startsWith('/aprs/packets') ? 'btn-primary' : 'btn-outline-light'}`}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// MeshCore-specific styles (briefing styles moved to shared ProtocolBriefing.scss)
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
@@ -31,6 +33,519 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meshcore-origin-stats-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
color: var(--app-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MeshCore-specific briefing sections (route bars, LoRa info, OSI layers, etc.)
|
||||||
|
|
||||||
|
.meshcore-briefing-route-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--app-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-route-bar {
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(173, 205, 255, 0.2);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
background: #40a9ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-route-bar--direct span {
|
||||||
|
background: #73d13d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-lora-card {
|
||||||
|
.card-body {
|
||||||
|
background: linear-gradient(180deg, rgba(11, 39, 82, 0.36), rgba(11, 39, 82, 0.08));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-lora-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-ops {
|
||||||
|
.card-body {
|
||||||
|
background: linear-gradient(180deg, rgba(11, 39, 82, 0.24), rgba(11, 39, 82, 0.08));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-ops-lead {
|
||||||
|
color: var(--app-text);
|
||||||
|
font-size: 1.02rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-ops-title {
|
||||||
|
color: var(--app-text);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-node-types {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-role-card {
|
||||||
|
border: 1px solid rgba(173, 205, 255, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(8, 24, 56, 0.45);
|
||||||
|
padding: 0.6rem 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-ascii {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.7rem 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(173, 205, 255, 0.2);
|
||||||
|
background: rgba(8, 24, 56, 0.62);
|
||||||
|
color: var(--app-text);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||||
|
font-size: 0.79rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-layers {
|
||||||
|
.card-body {
|
||||||
|
background: linear-gradient(180deg, rgba(11, 39, 82, 0.28), rgba(11, 39, 82, 0.08));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-layer-visual {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-layer-map {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-layer-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 1fr) minmax(260px, 2fr) max-content;
|
||||||
|
gap: 0.65rem;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid rgba(173, 205, 255, 0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
background: rgba(8, 24, 56, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-layer-name {
|
||||||
|
color: var(--app-text);
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-layer-desc {
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-layer-osi {
|
||||||
|
border: 1px solid rgba(173, 205, 255, 0.35);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.18rem 0.5rem;
|
||||||
|
color: var(--app-text);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: rgba(11, 39, 82, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.meshcore-briefing-layer-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-layer-osi {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-layer-col {
|
||||||
|
border: 1px solid rgba(173, 205, 255, 0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.65rem;
|
||||||
|
background: rgba(8, 24, 56, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-layer-pill {
|
||||||
|
border: 1px solid rgba(173, 205, 255, 0.16);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
color: var(--app-text);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
background: rgba(11, 39, 82, 0.3);
|
||||||
|
margin-top: 0.42rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-examples {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-example-row {
|
||||||
|
border-left: 3px solid rgba(90, 146, 255, 0.7);
|
||||||
|
padding: 0.3rem 0 0.3rem 0.65rem;
|
||||||
|
background: rgba(8, 24, 56, 0.22);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-example-label {
|
||||||
|
color: var(--app-text);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.meshcore-briefing-route-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--app-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-route-bar {
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(173, 205, 255, 0.2);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
background: #40a9ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-route-bar--direct span {
|
||||||
|
background: #73d13d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-lora-card {
|
||||||
|
.card-body {
|
||||||
|
background: linear-gradient(180deg, rgba(11, 39, 82, 0.36), rgba(11, 39, 82, 0.08));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-lora-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-ops {
|
||||||
|
.card-body {
|
||||||
|
background: linear-gradient(180deg, rgba(11, 39, 82, 0.24), rgba(11, 39, 82, 0.08));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-ops-lead {
|
||||||
|
color: var(--app-text);
|
||||||
|
font-size: 1.02rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-ops-title {
|
||||||
|
color: var(--app-text);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-node-types {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-role-card {
|
||||||
|
border: 1px solid rgba(173, 205, 255, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(8, 24, 56, 0.45);
|
||||||
|
padding: 0.6rem 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-ascii {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.7rem 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(173, 205, 255, 0.2);
|
||||||
|
background: rgba(8, 24, 56, 0.62);
|
||||||
|
color: var(--app-text);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||||
|
font-size: 0.79rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-layers {
|
||||||
|
.card-body {
|
||||||
|
background: linear-gradient(180deg, rgba(11, 39, 82, 0.28), rgba(11, 39, 82, 0.08));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-layer-visual {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-layer-map {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-layer-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 1fr) minmax(260px, 2fr) max-content;
|
||||||
|
gap: 0.65rem;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid rgba(173, 205, 255, 0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
background: rgba(8, 24, 56, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-layer-name {
|
||||||
|
color: var(--app-text);
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-layer-desc {
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-layer-osi {
|
||||||
|
border: 1px solid rgba(173, 205, 255, 0.35);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.18rem 0.5rem;
|
||||||
|
color: var(--app-text);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: rgba(11, 39, 82, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.meshcore-briefing-layer-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-layer-osi {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-layer-col {
|
||||||
|
border: 1px solid rgba(173, 205, 255, 0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.65rem;
|
||||||
|
background: rgba(8, 24, 56, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-layer-pill {
|
||||||
|
border: 1px solid rgba(173, 205, 255, 0.16);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
color: var(--app-text);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
background: rgba(11, 39, 82, 0.3);
|
||||||
|
margin-top: 0.42rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-examples {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-example-row {
|
||||||
|
border-left: 3px solid rgba(90, 146, 255, 0.7);
|
||||||
|
padding: 0.3rem 0 0.3rem 0.65rem;
|
||||||
|
background: rgba(8, 24, 56, 0.22);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-briefing-example-label {
|
||||||
|
color: var(--app-text);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-origin-stats-buttons {
|
||||||
|
.btn {
|
||||||
|
border-color: rgba(173, 205, 255, 0.45);
|
||||||
|
color: var(--app-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.btn-primary {
|
||||||
|
background: rgba(90, 146, 255, 0.5);
|
||||||
|
border-color: rgba(173, 205, 255, 0.75);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-origin-stats-chart {
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
|
.MuiChartsAxis-line,
|
||||||
|
.MuiChartsAxis-tick {
|
||||||
|
stroke: rgba(173, 205, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.MuiChartsAxis-tickLabel,
|
||||||
|
.MuiChartsLegend-label {
|
||||||
|
fill: var(--app-text-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-stats-body {
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-stats-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(760px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-stats-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-stats-section {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-chart-with-legend {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 220px;
|
||||||
|
align-items: stretch;
|
||||||
|
column-gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.meshcore-chart-with-legend {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
row-gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-custom-legend {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-chart-area {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.MuiChartsWrapper-root,
|
||||||
|
.MuiChartsSurface-root {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-custom-legend {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 220px;
|
||||||
|
background: rgba(11, 39, 82, 0.45);
|
||||||
|
border: 1px solid rgba(173, 205, 255, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.45rem 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-custom-legend-title {
|
||||||
|
color: var(--app-text);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-custom-legend-list {
|
||||||
|
max-height: 208px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-custom-legend-item {
|
||||||
|
background: #0b0b0d;
|
||||||
|
border: none;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
padding: 0.3rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-custom-legend-origin {
|
||||||
|
color: var(--app-text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-custom-legend-metrics {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-swatch {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.75rem;
|
||||||
|
height: 0.75rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid rgba(225, 237, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.meshcore-node-type-cell {
|
.meshcore-node-type-cell {
|
||||||
@@ -251,6 +766,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Compact monospaced hash badge */
|
||||||
|
.meshcore-hash {
|
||||||
|
/* Mirror SignalGrade styling */
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.08rem 0.4rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
font-weight: 100;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
min-width: 2.4rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--app-text);
|
||||||
|
background: transparent;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.meshcore-expand-button {
|
.meshcore-expand-button {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -384,6 +917,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meshcore-payload-group {
|
||||||
|
color: #717179;
|
||||||
|
}
|
||||||
|
|
||||||
.meshcore-ws-legend {
|
.meshcore-ws-legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
@@ -531,3 +1068,24 @@
|
|||||||
box-shadow: 0 0 10px rgba(168, 201, 255, 0.9);
|
box-shadow: 0 0 10px rgba(168, 201, 255, 0.9);
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meshcore-route-flood {
|
||||||
|
color: var(--app-status-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-route-direct {
|
||||||
|
color: var(--app-status-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-path {
|
||||||
|
color: var(--app-text-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-hash {
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshcore-encrypted {
|
||||||
|
color: var(--app-text-muted) !important;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ButtonGroup } from 'react-bootstrap';
|
import { ButtonGroup } from 'react-bootstrap';
|
||||||
import ChatIcon from '@mui/icons-material/Chat';
|
import ChatIcon from '@mui/icons-material/Chat';
|
||||||
|
import HubIcon from '@mui/icons-material/Hub';
|
||||||
|
import TimelineIcon from '@mui/icons-material/Timeline';
|
||||||
import MapIcon from '@mui/icons-material/Map';
|
import MapIcon from '@mui/icons-material/Map';
|
||||||
import StorageIcon from '@mui/icons-material/Storage';
|
import StorageIcon from '@mui/icons-material/Storage';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
@@ -18,6 +20,14 @@ const MeshCore: React.FC<Props> = ({ navLinks = [] }) => {
|
|||||||
|
|
||||||
const viewButtons = (
|
const viewButtons = (
|
||||||
<ButtonGroup className="meshcore-view-switch" size="sm" aria-label="MeshCore view switch">
|
<ButtonGroup className="meshcore-view-switch" size="sm" aria-label="MeshCore view switch">
|
||||||
|
<NavLink
|
||||||
|
to="/meshcore/stats"
|
||||||
|
className={`btn btn-sm btn-icon ${location.pathname.startsWith('/meshcore/stats') ? 'btn-primary' : 'btn-outline-light'}`}
|
||||||
|
title="Statistics"
|
||||||
|
>
|
||||||
|
<TimelineIcon className="icon" />
|
||||||
|
<span className="ms-1 d-none d-lg-inline">Stats</span>
|
||||||
|
</NavLink>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/meshcore/packets"
|
to="/meshcore/packets"
|
||||||
className={`btn btn-sm btn-icon ${location.pathname.startsWith('/meshcore/packets') ? 'btn-primary' : 'btn-outline-light'}`}
|
className={`btn btn-sm btn-icon ${location.pathname.startsWith('/meshcore/packets') ? 'btn-primary' : 'btn-outline-light'}`}
|
||||||
@@ -34,6 +44,14 @@ const MeshCore: React.FC<Props> = ({ navLinks = [] }) => {
|
|||||||
<ChatIcon className="icon" />
|
<ChatIcon className="icon" />
|
||||||
<span className="ms-1 d-none d-lg-inline">Chat</span>
|
<span className="ms-1 d-none d-lg-inline">Chat</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/meshcore/nodes"
|
||||||
|
className={`btn btn-sm btn-icon ${location.pathname.startsWith('/meshcore/nodes') ? 'btn-primary' : 'btn-outline-light'}`}
|
||||||
|
title="Nodes"
|
||||||
|
>
|
||||||
|
<HubIcon className="icon" />
|
||||||
|
<span className="ms-1 d-none d-lg-inline">Nodes</span>
|
||||||
|
</NavLink>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/meshcore/map"
|
to="/meshcore/map"
|
||||||
className={`btn btn-sm btn-icon ${location.pathname.startsWith('/meshcore/map') ? 'btn-primary' : 'btn-outline-light'}`}
|
className={`btn btn-sm btn-icon ${location.pathname.startsWith('/meshcore/map') ? 'btn-primary' : 'btn-outline-light'}`}
|
||||||
|
|||||||
@@ -1,3 +1,34 @@
|
|||||||
|
// Hero section styles
|
||||||
|
.overview-hero {
|
||||||
|
width: 100%;
|
||||||
|
background: linear-gradient(90deg, #0b2752 0%, #4a7bd4 60%, #8eb4ff 100%);
|
||||||
|
color: var(--app-text);
|
||||||
|
color: #fff;
|
||||||
|
padding: 3rem 0 2rem 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
box-shadow: 0 2px 12px rgba(26, 35, 126, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-hero-content {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-hero-title {
|
||||||
|
font-size: 2.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-hero-subtitle {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-bottom: 0;
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
.overview-container {
|
.overview-container {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
import { Card } from "react-bootstrap";
|
||||||
import Layout from "../components/Layout";
|
import Layout from "../components/Layout";
|
||||||
|
import Full from "../components/Full";
|
||||||
import RadioCard from "../components/RadioCard";
|
import RadioCard from "../components/RadioCard";
|
||||||
import { useRadios } from "../contexts/RadiosContext";
|
import { useRadios } from "../contexts/RadiosContext";
|
||||||
import type { NavLinkItem } from "../types/layout.types";
|
import type { NavLinkItem } from "../types/layout.types";
|
||||||
@@ -7,7 +9,7 @@ import type { Radio } from "../types/radio.types";
|
|||||||
import './Overview.scss';
|
import './Overview.scss';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
navLinks?: NavLinkItem[]
|
navLinks?: NavLinkItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_RADIOS_PER_ROW = 6;
|
const MAX_RADIOS_PER_ROW = 6;
|
||||||
@@ -45,9 +47,20 @@ export const Overview: React.FC<Props> = ({ navLinks = [] }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout navLinks={navLinks}>
|
<Layout navLinks={navLinks}>
|
||||||
|
<Full>
|
||||||
|
<div className="overview-hero">
|
||||||
|
<div className="overview-hero-content">
|
||||||
|
<h1 className="overview-hero-title">Welcome to Digi View</h1>
|
||||||
|
<p className="overview-hero-subtitle">
|
||||||
|
Live Digital Radio Protocol Viewer<br />
|
||||||
|
Explore online and offline radios, real-time packet streams, and protocol inspection.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Card body className="data-table-card overview-card">
|
||||||
<div className="overview-container">
|
<div className="overview-container">
|
||||||
<section className="overview-section">
|
<section className="overview-section">
|
||||||
<h1 className="overview-title">Radios ({totalRadiosCount})</h1>
|
<h2 className="overview-title">Radios ({totalRadiosCount})</h2>
|
||||||
|
|
||||||
{loading && <div className="overview-loading">Loading radios…</div>}
|
{loading && <div className="overview-loading">Loading radios…</div>}
|
||||||
|
|
||||||
@@ -60,7 +73,7 @@ export const Overview: React.FC<Props> = ({ navLinks = [] }) => {
|
|||||||
{!loading && !error && radios.length > 0 && (
|
{!loading && !error && radios.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="overview-category">
|
<div className="overview-category">
|
||||||
<h2 className="overview-category-title">Online ({onlineRadiosCount})</h2>
|
<h3 className="overview-category-title">Online ({onlineRadiosCount})</h3>
|
||||||
{onlineRows.length === 0 ? (
|
{onlineRows.length === 0 ? (
|
||||||
<div className="overview-empty">No online radios.</div>
|
<div className="overview-empty">No online radios.</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -78,11 +91,9 @@ export const Overview: React.FC<Props> = ({ navLinks = [] }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{offlineRows.length > 0 && (
|
||||||
<div className="overview-category">
|
<div className="overview-category">
|
||||||
<h2 className="overview-category-title">Offline ({offlineRadiosCount})</h2>
|
<h3 className="overview-category-title">Offline ({offlineRadiosCount})</h3>
|
||||||
{offlineRows.length === 0 ? (
|
|
||||||
<div className="overview-empty">No offline radios.</div>
|
|
||||||
) : (
|
|
||||||
<div className="overview-radios-rows">
|
<div className="overview-radios-rows">
|
||||||
{offlineRows.map((row, rowIndex) => (
|
{offlineRows.map((row, rowIndex) => (
|
||||||
<div className="overview-radios-row" key={`overview-offline-row-${rowIndex}`}>
|
<div className="overview-radios-row" key={`overview-offline-row-${rowIndex}`}>
|
||||||
@@ -94,14 +105,15 @@ export const Overview: React.FC<Props> = ({ navLinks = [] }) => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Full>
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Overview
|
export default Overview
|
||||||
|
|||||||
25
ui/src/pages/adsb/ADSBContext.tsx
Normal file
25
ui/src/pages/adsb/ADSBContext.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { createContext } from 'react';
|
||||||
|
import type { Frame } from '../../protocols/adsb';
|
||||||
|
import type { Packet } from '../../types/protocol.types';
|
||||||
|
|
||||||
|
export interface ADSBPacketRecord extends Packet {
|
||||||
|
timestamp: Date;
|
||||||
|
raw: Uint8Array;
|
||||||
|
frame: Frame;
|
||||||
|
icao?: string;
|
||||||
|
callsign?: string;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
altitude?: number;
|
||||||
|
groundSpeed?: number;
|
||||||
|
trackAngle?: number;
|
||||||
|
verticalRate?: number;
|
||||||
|
radioName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ADSBDataContextValue {
|
||||||
|
packets: ADSBPacketRecord[];
|
||||||
|
streamReady: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ADSBDataContext = createContext<ADSBDataContextValue | null>(null);
|
||||||
83
ui/src/pages/adsb/ADSBData.tsx
Normal file
83
ui/src/pages/adsb/ADSBData.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import React, { useContext, useMemo, useState, useEffect } from 'react';
|
||||||
|
import { ADSBDataContext, type ADSBDataContextValue, type ADSBPacketRecord } from './ADSBContext';
|
||||||
|
import { ADSBFrame } from '../../protocols/adsb';
|
||||||
|
|
||||||
|
export { ADSBDataContext, type ADSBDataContextValue, type ADSBPacketRecord } from './ADSBContext';
|
||||||
|
|
||||||
|
export const useADSBData = (): ADSBDataContextValue => {
|
||||||
|
const context = useContext(ADSBDataContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useADSBData must be used within ADSBDataProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Service and limits will be added when the real API is available
|
||||||
|
|
||||||
|
export const ADSBDataProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [packets, setPackets] = useState<ADSBPacketRecord[]>([]);
|
||||||
|
const [streamReady, setStreamReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
// MOCK DATA GENERATION (remove when API is ready)
|
||||||
|
const generateMockPackets = (): ADSBPacketRecord[] => {
|
||||||
|
const now = new Date();
|
||||||
|
return Array.from({ length: 20 }, (_, i) => {
|
||||||
|
const lat = 52.0 + Math.random();
|
||||||
|
const lng = 4.0 + Math.random();
|
||||||
|
const rawArr = new Uint8Array([0x8d, 0x48, 0x40, 0x58, 0x58, 0x58, 0x58, 0x58]);
|
||||||
|
const frame = new ADSBFrame();
|
||||||
|
frame.raw = rawArr;
|
||||||
|
return {
|
||||||
|
timestamp: new Date(now.getTime() - i * 60000),
|
||||||
|
receivedAt: new Date(now.getTime() - i * 60000),
|
||||||
|
raw: rawArr,
|
||||||
|
frame,
|
||||||
|
icao: `4840${(1000 + i).toString(16)}`,
|
||||||
|
callsign: `PH-TEST${i}`,
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lng,
|
||||||
|
altitude: 10000 + i * 100,
|
||||||
|
groundSpeed: 200 + i,
|
||||||
|
trackAngle: (i * 10) % 360,
|
||||||
|
verticalRate: (i % 2 === 0 ? 500 : -500),
|
||||||
|
radioName: 'MockRadio',
|
||||||
|
snr: 10 + Math.random() * 5,
|
||||||
|
rssi: -80 + Math.random() * 10,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
setPackets(generateMockPackets());
|
||||||
|
setStreamReady(true);
|
||||||
|
return () => {
|
||||||
|
setPackets([]);
|
||||||
|
setStreamReady(false);
|
||||||
|
};
|
||||||
|
// END MOCK DATA
|
||||||
|
|
||||||
|
// Simulate stream ready state
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (isMounted) {
|
||||||
|
setStreamReady(true);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<ADSBDataContextValue>(
|
||||||
|
() => ({
|
||||||
|
packets,
|
||||||
|
streamReady,
|
||||||
|
}),
|
||||||
|
[packets, streamReady]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <ADSBDataContext.Provider value={value}>{children}</ADSBDataContext.Provider>;
|
||||||
|
};
|
||||||
357
ui/src/pages/adsb/ADSBPacketsView.tsx
Normal file
357
ui/src/pages/adsb/ADSBPacketsView.tsx
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
import React, { useMemo, useState, useRef } from 'react';
|
||||||
|
import { Card, Table } from 'react-bootstrap';
|
||||||
|
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
|
||||||
|
import { divIcon, type DivIcon } from 'leaflet';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
import PlaneIcon from '@mui/icons-material/AirplanemodeActive';
|
||||||
|
import VerticalSplit from '../../components/VerticalSplit';
|
||||||
|
import HorizontalSplit from '../../components/HorizontalSplit';
|
||||||
|
import PacketDissectionViewer from '../../components/PacketDissectionViewer';
|
||||||
|
import KeyboardShortcutsModal from '../../components/KeyboardShortcutsModal';
|
||||||
|
import StreamStatus from '../../components/StreamStatus';
|
||||||
|
import { useKeyboardListNavigation } from '../../hooks/useKeyboardListNavigation';
|
||||||
|
import { useADSBData } from './ADSBData';
|
||||||
|
import type { ADSBPacketRecord } from './ADSBContext';
|
||||||
|
import '../APRS.scss';
|
||||||
|
|
||||||
|
const getPacketKey = (packet: ADSBPacketRecord): string => {
|
||||||
|
return `${packet.icao}-${packet.timestamp.toISOString()}-${packet.timestamp.getMilliseconds()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateBounds = (packets: ADSBPacketRecord[]): [[number, number], [number, number]] | null => {
|
||||||
|
const validPackets = packets.filter((p) => p.latitude !== undefined && p.longitude !== undefined);
|
||||||
|
|
||||||
|
if (validPackets.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validPackets.length === 1) {
|
||||||
|
const lat = validPackets[0].latitude!;
|
||||||
|
const lng = validPackets[0].longitude!;
|
||||||
|
const offset = 2;
|
||||||
|
return [
|
||||||
|
[lat - offset, lng - offset],
|
||||||
|
[lat + offset, lng + offset],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
let minLat = validPackets[0].latitude!;
|
||||||
|
let maxLat = validPackets[0].latitude!;
|
||||||
|
let minLng = validPackets[0].longitude!;
|
||||||
|
let maxLng = validPackets[0].longitude!;
|
||||||
|
|
||||||
|
validPackets.forEach((packet) => {
|
||||||
|
const lat = packet.latitude!;
|
||||||
|
const lng = packet.longitude!;
|
||||||
|
if (lat < minLat) minLat = lat;
|
||||||
|
if (lat > maxLat) maxLat = lat;
|
||||||
|
if (lng < minLng) minLng = lng;
|
||||||
|
if (lng > maxLng) maxLng = lng;
|
||||||
|
});
|
||||||
|
|
||||||
|
const latPadding = (maxLat - minLat) * 0.1 || 0.5;
|
||||||
|
const lngPadding = (maxLng - minLng) * 0.1 || 0.5;
|
||||||
|
|
||||||
|
return [
|
||||||
|
[minLat - latPadding, minLng - lngPadding],
|
||||||
|
[maxLat + latPadding, maxLng + lngPadding],
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const MapBoundsUpdater: React.FC<{ bounds: [[number, number], [number, number]] | null }> = ({ bounds }) => {
|
||||||
|
const map = useMap();
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (bounds) {
|
||||||
|
map.fitBounds(bounds, { padding: [50, 50] });
|
||||||
|
}
|
||||||
|
}, [bounds, map]);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ADSBPacketsView: React.FC = () => {
|
||||||
|
const { packets, streamReady } = useADSBData();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false);
|
||||||
|
|
||||||
|
const selectedIndex = useMemo(() => {
|
||||||
|
const param = searchParams.get('packet');
|
||||||
|
return param ? parseInt(param, 10) : null;
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const selectedPacket = selectedIndex !== null ? packets[selectedIndex] : undefined;
|
||||||
|
const listScrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const bounds = useMemo(() => calculateBounds(packets), [packets]);
|
||||||
|
|
||||||
|
const { shortcuts } = useKeyboardListNavigation({
|
||||||
|
itemCount: packets.length,
|
||||||
|
selectedIndex,
|
||||||
|
onSelectIndex: (index) => {
|
||||||
|
if (index === null) {
|
||||||
|
setSearchParams({}, { replace: true });
|
||||||
|
} else {
|
||||||
|
setSearchParams({ packet: index.toString() }, { replace: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scrollContainerRef: listScrollRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const mapMarkers = useMemo(() => {
|
||||||
|
return packets
|
||||||
|
.filter((packet) => packet.latitude !== undefined && packet.longitude !== undefined)
|
||||||
|
.map((packet) => {
|
||||||
|
const isSelected = packet === selectedPacket;
|
||||||
|
const icon: DivIcon = divIcon({
|
||||||
|
html: renderToStaticMarkup(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
backgroundColor: isSelected ? '#0d6efd' : '#6c757d',
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: '2px solid white',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.3)',
|
||||||
|
fontSize: '16px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlaneIcon style={{ color: 'white', width: '20px', height: '20px' }} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
className: 'adsb-marker',
|
||||||
|
iconSize: [32, 32],
|
||||||
|
iconAnchor: [16, 16],
|
||||||
|
popupAnchor: [0, -16],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Marker key={getPacketKey(packet)} position={[packet.latitude!, packet.longitude!]} icon={icon}>
|
||||||
|
<Popup>
|
||||||
|
<div className="p-2">
|
||||||
|
<strong>{packet.callsign || packet.icao || 'Unknown'}</strong>
|
||||||
|
{packet.altitude && (
|
||||||
|
<>
|
||||||
|
<br /> Altitude: {packet.altitude.toLocaleString()} ft
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{packet.groundSpeed && (
|
||||||
|
<>
|
||||||
|
<br /> Speed: {packet.groundSpeed.toFixed(1)} kt
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{packet.trackAngle && (
|
||||||
|
<>
|
||||||
|
<br /> Track: {packet.trackAngle.toFixed(1)}°
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [packets, selectedPacket]);
|
||||||
|
|
||||||
|
const dissectionSegments = useMemo(() => {
|
||||||
|
if (!selectedPacket?.frame) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return selectedPacket.frame.segments || [];
|
||||||
|
}, [selectedPacket]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="aprs-packets-view">
|
||||||
|
<VerticalSplit
|
||||||
|
left={
|
||||||
|
<HorizontalSplit
|
||||||
|
top={
|
||||||
|
<Card className="h-100 d-flex flex-column rounded-0 shadow-none border-0">
|
||||||
|
<Card.Header className="px-3 py-2 bg-dark d-flex align-items-center justify-content-between">
|
||||||
|
<span>
|
||||||
|
ADSB Packets {packets.length > 0 && <span className="text-muted">({packets.length})</span>}
|
||||||
|
</span>
|
||||||
|
<StreamStatus ready={streamReady} />
|
||||||
|
</Card.Header>
|
||||||
|
<div ref={listScrollRef} className="flex-grow-1 overflow-auto">
|
||||||
|
<Table className="mb-0 table-sm table-dark" hover={true}>
|
||||||
|
<thead className="position-sticky top-0 bg-dark">
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: '120px' }}>Time</th>
|
||||||
|
<th style={{ width: '100px' }}>ICAO</th>
|
||||||
|
<th style={{ width: '150px' }}>Callsign</th>
|
||||||
|
<th style={{ width: '80px', textAlign: 'right' }}>Altitude</th>
|
||||||
|
<th style={{ width: '80px', textAlign: 'right' }}>Speed</th>
|
||||||
|
<th style={{ width: '70px', textAlign: 'right' }}>SNR</th>
|
||||||
|
<th style={{ width: '70px', textAlign: 'right' }}>RSSI</th>
|
||||||
|
<th style={{ width: '150px' }}>Radio</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{packets.map((packet, index) => (
|
||||||
|
<tr
|
||||||
|
key={getPacketKey(packet)}
|
||||||
|
data-nav-item="true"
|
||||||
|
className={index === selectedIndex ? 'table-active' : ''}
|
||||||
|
onClick={() => setSearchParams({ packet: index.toString() }, { replace: true })}
|
||||||
|
>
|
||||||
|
<td className="text-nowrap text-monospace" style={{ width: '120px' }}>
|
||||||
|
{packet.timestamp.toLocaleTimeString()}
|
||||||
|
</td>
|
||||||
|
<td className="text-nowrap text-monospace" style={{ width: '100px' }}>
|
||||||
|
{packet.icao || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="text-nowrap" style={{ width: '150px' }}>
|
||||||
|
{packet.callsign || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="text-nowrap text-end" style={{ width: '80px' }}>
|
||||||
|
{packet.altitude ? `${packet.altitude.toLocaleString()} ft` : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="text-nowrap text-end" style={{ width: '80px' }}>
|
||||||
|
{packet.groundSpeed ? `${packet.groundSpeed.toFixed(1)} kt` : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="text-nowrap text-end" style={{ width: '70px' }}>
|
||||||
|
{packet.snr ? `${packet.snr.toFixed(1)} dB` : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="text-nowrap text-end" style={{ width: '70px' }}>
|
||||||
|
{packet.rssi ? `${packet.rssi.toFixed(1)} dBm` : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="text-muted" style={{ width: '150px' }}>
|
||||||
|
{packet.radioName || '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
bottom={
|
||||||
|
<Card className="h-100 d-flex flex-column rounded-0 shadow-none border-0">
|
||||||
|
<Card.Header className="px-3 py-2 bg-dark">Map</Card.Header>
|
||||||
|
<div className="flex-grow-1">
|
||||||
|
{bounds ? (
|
||||||
|
<MapContainer className="h-100" zoom={4} bounds={bounds}>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
<MapBoundsUpdater bounds={bounds} />
|
||||||
|
{mapMarkers}
|
||||||
|
</MapContainer>
|
||||||
|
) : (
|
||||||
|
<div className="h-100 d-flex align-items-center justify-content-center text-muted">
|
||||||
|
No location data available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
right={
|
||||||
|
<Card className="h-100 d-flex flex-column rounded-0 shadow-none border-0">
|
||||||
|
<Card.Header className="px-3 py-2 bg-dark d-flex align-items-center justify-content-between">
|
||||||
|
<span>Packet Details</span>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline-light"
|
||||||
|
onClick={() => setShowKeyboardHelp(!showKeyboardHelp)}
|
||||||
|
title="Keyboard shortcuts"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</button>
|
||||||
|
</Card.Header>
|
||||||
|
<div className="flex-grow-1 overflow-auto">
|
||||||
|
{selectedPacket ? (
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="mb-3">
|
||||||
|
<h6 className="text-muted text-uppercase mb-2">Aircraft Information</h6>
|
||||||
|
<div className="row g-2">
|
||||||
|
{selectedPacket.icao && (
|
||||||
|
<div className="col-6">
|
||||||
|
<div className="text-muted small">ICAO</div>
|
||||||
|
<div className="text-monospace font-weight-bold">{selectedPacket.icao}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedPacket.callsign && (
|
||||||
|
<div className="col-6">
|
||||||
|
<div className="text-muted small">Callsign</div>
|
||||||
|
<div className="text-monospace font-weight-bold">{selectedPacket.callsign}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedPacket.altitude && (
|
||||||
|
<div className="col-6">
|
||||||
|
<div className="text-muted small">Altitude</div>
|
||||||
|
<div>{selectedPacket.altitude.toLocaleString()} feet</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedPacket.groundSpeed && (
|
||||||
|
<div className="col-6">
|
||||||
|
<div className="text-muted small">Ground Speed</div>
|
||||||
|
<div>{selectedPacket.groundSpeed.toFixed(1)} knots</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedPacket.trackAngle && (
|
||||||
|
<div className="col-6">
|
||||||
|
<div className="text-muted small">Track Angle</div>
|
||||||
|
<div>{selectedPacket.trackAngle.toFixed(1)}°</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedPacket.verticalRate && (
|
||||||
|
<div className="col-6">
|
||||||
|
<div className="text-muted small">Vertical Rate</div>
|
||||||
|
<div>{selectedPacket.verticalRate.toFixed(0)} ft/min</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedPacket.snr !== undefined || selectedPacket.rssi !== undefined ? (
|
||||||
|
<div className="mb-3">
|
||||||
|
<h6 className="text-muted text-uppercase mb-2">Signal Quality</h6>
|
||||||
|
<div className="row g-2">
|
||||||
|
{selectedPacket.snr !== undefined && (
|
||||||
|
<div className="col-6">
|
||||||
|
<div className="text-muted small">SNR</div>
|
||||||
|
<div>{selectedPacket.snr.toFixed(1)} dB</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedPacket.rssi !== undefined && (
|
||||||
|
<div className="col-6">
|
||||||
|
<div className="text-muted small">RSSI</div>
|
||||||
|
<div>{selectedPacket.rssi.toFixed(1)} dBm</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{dissectionSegments.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<h6 className="text-muted text-uppercase mb-2">Packet Dissection</h6>
|
||||||
|
<PacketDissectionViewer segments={dissectionSegments} rawPacket={selectedPacket.frame?.raw} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-100 d-flex align-items-center justify-content-center text-muted">
|
||||||
|
Select a packet to view details
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<KeyboardShortcutsModal
|
||||||
|
show={showKeyboardHelp}
|
||||||
|
onHide={() => setShowKeyboardHelp(false)}
|
||||||
|
shortcuts={shortcuts}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ADSBPacketsView;
|
||||||
311
ui/src/pages/aprs/APRSView.tsx
Normal file
311
ui/src/pages/aprs/APRSView.tsx
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { Alert, Badge, Card, Spinner } from 'react-bootstrap';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { KPICard } from '../../components/protocol/KPICard';
|
||||||
|
import { ProtocolHero } from '../../components/protocol/ProtocolHero';
|
||||||
|
import { RadioListItem } from '../../components/protocol/RadioListItem';
|
||||||
|
import { StatusPill } from '../../components/protocol/StatusPill';
|
||||||
|
import { useRadios } from '../../contexts/RadiosContext';
|
||||||
|
import '../../styles/ProtocolBriefing.scss';
|
||||||
|
|
||||||
|
import { useAPRSData } from './APRSData';
|
||||||
|
|
||||||
|
const formatCallsign = (call: string, ssid?: string | number): string => {
|
||||||
|
if (ssid === undefined || ssid === '' || ssid === 0 || ssid === '0') {
|
||||||
|
return call;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${call}-${ssid}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTrafficCondition = (packetsLastHour: number): { label: string; tone: 'success' | 'warning' | 'danger' } => {
|
||||||
|
if (packetsLastHour >= 60) {
|
||||||
|
return { label: 'Busy', tone: 'success' };
|
||||||
|
}
|
||||||
|
if (packetsLastHour >= 20) {
|
||||||
|
return { label: 'Moderate', tone: 'warning' };
|
||||||
|
}
|
||||||
|
return { label: 'Quiet', tone: 'danger' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCoverageCondition = (positionRatio: number): { label: string; tone: 'success' | 'warning' | 'danger' } => {
|
||||||
|
if (positionRatio >= 0.65) {
|
||||||
|
return { label: 'Position-rich', tone: 'success' };
|
||||||
|
}
|
||||||
|
if (positionRatio >= 0.35) {
|
||||||
|
return { label: 'Mixed', tone: 'warning' };
|
||||||
|
}
|
||||||
|
return { label: 'Message-heavy', tone: 'danger' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const APRSView: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { packets, streamReady } = useAPRSData();
|
||||||
|
const { radios, loading: radiosLoading, error: radiosError } = useRadios();
|
||||||
|
|
||||||
|
const aprsRadios = useMemo(() => {
|
||||||
|
return radios
|
||||||
|
.filter((radio) => radio.protocol === 'aprs')
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.is_online !== right.is_online) {
|
||||||
|
return left.is_online ? -1 : 1;
|
||||||
|
}
|
||||||
|
return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' });
|
||||||
|
});
|
||||||
|
}, [radios]);
|
||||||
|
|
||||||
|
const aprsOnlineCount = aprsRadios.filter((radio) => radio.is_online).length;
|
||||||
|
|
||||||
|
const snapshot = useMemo(() => {
|
||||||
|
if (packets.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const oneHourMs = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const sourceCounts = new Map<string, number>();
|
||||||
|
const symbolCounts = new Map<string, number>();
|
||||||
|
const radioCounts = new Map<string, number>();
|
||||||
|
const uniqueDestinations = new Set<string>();
|
||||||
|
|
||||||
|
let positionPackets = 0;
|
||||||
|
let withCommentCount = 0;
|
||||||
|
let packetsLastHour = 0;
|
||||||
|
let snrTotal = 0;
|
||||||
|
let snrSamples = 0;
|
||||||
|
let rssiTotal = 0;
|
||||||
|
let rssiSamples = 0;
|
||||||
|
|
||||||
|
packets.forEach((packet) => {
|
||||||
|
const source = formatCallsign(packet.frame.source.call, packet.frame.source.ssid);
|
||||||
|
sourceCounts.set(source, (sourceCounts.get(source) ?? 0) + 1);
|
||||||
|
|
||||||
|
const destination = formatCallsign(packet.frame.destination.call, packet.frame.destination.ssid);
|
||||||
|
uniqueDestinations.add(destination);
|
||||||
|
|
||||||
|
if (packet.symbol) {
|
||||||
|
symbolCounts.set(packet.symbol, (symbolCounts.get(packet.symbol) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packet.radioName) {
|
||||||
|
radioCounts.set(packet.radioName, (radioCounts.get(packet.radioName) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packet.latitude !== undefined && packet.longitude !== undefined) {
|
||||||
|
positionPackets += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packet.comment && packet.comment.trim().length > 0) {
|
||||||
|
withCommentCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nowMs - packet.timestamp.getTime() <= oneHourMs) {
|
||||||
|
packetsLastHour += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof packet.snr === 'number' && Number.isFinite(packet.snr)) {
|
||||||
|
snrTotal += packet.snr;
|
||||||
|
snrSamples += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof packet.rssi === 'number' && Number.isFinite(packet.rssi)) {
|
||||||
|
rssiTotal += packet.rssi;
|
||||||
|
rssiSamples += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedSources = Array.from(sourceCounts.entries()).sort((left, right) => right[1] - left[1]);
|
||||||
|
const sortedSymbols = Array.from(symbolCounts.entries()).sort((left, right) => right[1] - left[1]);
|
||||||
|
const sortedRadios = Array.from(radioCounts.entries()).sort((left, right) => right[1] - left[1]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalPackets: packets.length,
|
||||||
|
uniqueSources: sourceCounts.size,
|
||||||
|
uniqueDestinations: uniqueDestinations.size,
|
||||||
|
positionPackets,
|
||||||
|
positionRatio: packets.length > 0 ? positionPackets / packets.length : 0,
|
||||||
|
commentRatio: packets.length > 0 ? withCommentCount / packets.length : 0,
|
||||||
|
packetsLastHour,
|
||||||
|
avgSnr: snrSamples > 0 ? snrTotal / snrSamples : null,
|
||||||
|
avgRssi: rssiSamples > 0 ? rssiTotal / rssiSamples : null,
|
||||||
|
topSource: sortedSources[0] ?? null,
|
||||||
|
topSymbol: sortedSymbols[0] ?? null,
|
||||||
|
busiestRadio: sortedRadios[0] ?? null,
|
||||||
|
latestPacketTime: packets[0].timestamp,
|
||||||
|
oldestPacketTime: packets[packets.length - 1].timestamp,
|
||||||
|
};
|
||||||
|
}, [packets]);
|
||||||
|
|
||||||
|
const trafficCondition = snapshot ? getTrafficCondition(snapshot.packetsLastHour) : null;
|
||||||
|
const coverageCondition = snapshot ? getCoverageCondition(snapshot.positionRatio) : null;
|
||||||
|
|
||||||
|
const openRadioPackets = (radioName: string) => {
|
||||||
|
navigate(`/aprs/packets?radio=${encodeURIComponent(radioName)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!snapshot) {
|
||||||
|
return (
|
||||||
|
<div className="protocol-briefing">
|
||||||
|
<ProtocolHero
|
||||||
|
protocolBadges={[
|
||||||
|
{ label: 'APRS', variant: 'info' },
|
||||||
|
{ label: 'AX.25 / UI Frames', variant: 'secondary' },
|
||||||
|
{ label: 'Operator Briefing', variant: 'dark' },
|
||||||
|
]}
|
||||||
|
title="Operator Briefing"
|
||||||
|
description={
|
||||||
|
<p className="mb-0">
|
||||||
|
APRS carries tactical position, messaging, weather, and telemetry traffic over amateur packet radio.
|
||||||
|
This briefing gives a practical network snapshot once packet data is available.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
logoSrc="/image/protocol/aprs.png"
|
||||||
|
logoAlt="APRS"
|
||||||
|
logoLabel="APRS Protocol"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card className="data-table-card">
|
||||||
|
<Card.Body className="d-flex align-items-center gap-2 text-secondary">
|
||||||
|
<Spinner animation="border" size="sm" />
|
||||||
|
Waiting for APRS packets…
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="protocol-briefing">
|
||||||
|
<ProtocolHero
|
||||||
|
protocolBadges={[
|
||||||
|
{ label: 'APRS', variant: 'info' },
|
||||||
|
{ label: 'AX.25 / UI Frames', variant: 'secondary' },
|
||||||
|
{ label: 'Live Network Snapshot', variant: 'dark' },
|
||||||
|
]}
|
||||||
|
title="Operator Briefing"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<p className="mb-2">
|
||||||
|
APRS is a shared situational channel for amateur operators: position beacons, short messages,
|
||||||
|
telemetry, and weather data carried over packet radio and digipeater paths.
|
||||||
|
</p>
|
||||||
|
<p className="mb-0">
|
||||||
|
This page summarizes current activity from the latest packet window, with emphasis on station
|
||||||
|
diversity, position-report density, and RF reception quality where metadata is available.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
logoSrc="/image/protocol/aprs.png"
|
||||||
|
logoAlt="APRS"
|
||||||
|
logoLabel="Automatic Packet Reporting System"
|
||||||
|
statusPills={
|
||||||
|
trafficCondition && coverageCondition ? (
|
||||||
|
<>
|
||||||
|
<StatusPill label="Traffic" value={trafficCondition.label} tone={trafficCondition.tone} />
|
||||||
|
<StatusPill label="Payload mix" value={coverageCondition.label} tone={coverageCondition.tone} />
|
||||||
|
<StatusPill label="Stream" value={streamReady ? 'Online' : 'Offline'} tone={streamReady ? 'success' : 'secondary'} />
|
||||||
|
</>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="protocol-briefing-kpis">
|
||||||
|
<KPICard
|
||||||
|
label="Unique Sources"
|
||||||
|
value={snapshot.uniqueSources}
|
||||||
|
description="Distinct transmitting callsigns in this packet window"
|
||||||
|
/>
|
||||||
|
<KPICard
|
||||||
|
label="Packet Volume"
|
||||||
|
value={snapshot.totalPackets.toLocaleString()}
|
||||||
|
description={`${snapshot.packetsLastHour.toLocaleString()} packets seen in the last hour`}
|
||||||
|
/>
|
||||||
|
<KPICard
|
||||||
|
label="Position Reports"
|
||||||
|
value={`${(snapshot.positionRatio * 100).toFixed(1)}%`}
|
||||||
|
description={`${snapshot.positionPackets.toLocaleString()} packets include valid coordinates`}
|
||||||
|
/>
|
||||||
|
<KPICard
|
||||||
|
label="RF Envelope"
|
||||||
|
value={
|
||||||
|
<>
|
||||||
|
{snapshot.avgSnr !== null ? `${snapshot.avgSnr.toFixed(1)} dB` : 'n/a'}
|
||||||
|
{' / '}
|
||||||
|
{snapshot.avgRssi !== null ? `${snapshot.avgRssi.toFixed(1)} dBm` : 'n/a'}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
description="Average SNR and RSSI from packets with receiver metadata"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="data-table-card">
|
||||||
|
<Card.Header className="data-table-header d-flex align-items-center justify-content-between gap-2">
|
||||||
|
<span>Registered APRS Radios</span>
|
||||||
|
<Badge bg="secondary">{aprsOnlineCount}/{aprsRadios.length} online</Badge>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body>
|
||||||
|
{radiosLoading ? (
|
||||||
|
<div className="d-flex align-items-center gap-2 text-secondary small">
|
||||||
|
<Spinner animation="border" size="sm" />
|
||||||
|
Loading radio registry…
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!radiosLoading && radiosError ? (
|
||||||
|
<Alert variant="warning" className="mb-0 py-2">
|
||||||
|
{radiosError}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!radiosLoading && !radiosError && aprsRadios.length === 0 ? (
|
||||||
|
<div className="text-secondary small">No radios registered for APRS.</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!radiosLoading && !radiosError && aprsRadios.length > 0 ? (
|
||||||
|
<div className="protocol-briefing-radio-list">
|
||||||
|
{aprsRadios.map((radio) => (
|
||||||
|
<RadioListItem key={radio.id} radio={radio} onRadioClick={openRadioPackets} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="protocol-briefing-columns">
|
||||||
|
<Card className="data-table-card">
|
||||||
|
<Card.Header className="data-table-header">Operational Snapshot</Card.Header>
|
||||||
|
<Card.Body>
|
||||||
|
<p className="mb-2 text-secondary">
|
||||||
|
The current sample spans from <strong>{snapshot.oldestPacketTime.toLocaleString()}</strong> to{' '}
|
||||||
|
<strong>{snapshot.latestPacketTime.toLocaleString()}</strong>. The most active source is{' '}
|
||||||
|
<strong>{snapshot.topSource ? snapshot.topSource[0] : 'n/a'}</strong>
|
||||||
|
{snapshot.topSource ? ` (${snapshot.topSource[1].toLocaleString()} packets)` : ''}.
|
||||||
|
</p>
|
||||||
|
<p className="mb-0 text-secondary">
|
||||||
|
{snapshot.busiestRadio
|
||||||
|
? `The busiest receiving radio in this window is ${snapshot.busiestRadio[0]} with ${snapshot.busiestRadio[1].toLocaleString()} packets.`
|
||||||
|
: 'Receiver-to-packet attribution is limited in this sample window.'}
|
||||||
|
</p>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="data-table-card">
|
||||||
|
<Card.Header className="data-table-header">How to Interpret APRS Health</Card.Header>
|
||||||
|
<Card.Body>
|
||||||
|
<ul className="protocol-briefing-list mb-0">
|
||||||
|
<li><strong>Station diversity:</strong> more unique sources generally means stronger regional participation.</li>
|
||||||
|
<li><strong>Position ratio:</strong> indicates how location-centric the channel is versus text/telemetry traffic.</li>
|
||||||
|
<li><strong>Destination spread:</strong> {snapshot.uniqueDestinations.toLocaleString()} unique destinations in this sample.</li>
|
||||||
|
<li><strong>Symbol activity:</strong> top symbol {snapshot.topSymbol ? `${snapshot.topSymbol[0]} (${snapshot.topSymbol[1]})` : 'n/a'} helps identify dominant station class.</li>
|
||||||
|
<li><strong>Message presence:</strong> {(snapshot.commentRatio * 100).toFixed(1)}% of packets carry human-readable comment text.</li>
|
||||||
|
</ul>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default APRSView;
|
||||||
@@ -1,20 +1,5 @@
|
|||||||
import { createContext, useContext } from 'react';
|
import { createContext, useContext } from 'react';
|
||||||
|
import type { Packet } from '../../protocols/meshcore';
|
||||||
export interface MeshCorePacketRecord {
|
|
||||||
timestamp: Date;
|
|
||||||
hash: string;
|
|
||||||
nodeType: number;
|
|
||||||
payloadType: number;
|
|
||||||
routeType: number;
|
|
||||||
version: number;
|
|
||||||
path: Uint8Array;
|
|
||||||
raw: Uint8Array;
|
|
||||||
decodedPayload?: unknown;
|
|
||||||
payloadSummary: string;
|
|
||||||
radioName?: string;
|
|
||||||
snr?: number;
|
|
||||||
rssi?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MeshCoreGroupChatRecord {
|
export interface MeshCoreGroupChatRecord {
|
||||||
hash: string;
|
hash: string;
|
||||||
@@ -33,7 +18,7 @@ export interface MeshCoreNodePoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface MeshCoreDataContextValue {
|
export interface MeshCoreDataContextValue {
|
||||||
packets: MeshCorePacketRecord[];
|
packets: Packet[];
|
||||||
groupChats: MeshCoreGroupChatRecord[];
|
groupChats: MeshCoreGroupChatRecord[];
|
||||||
mapPoints: MeshCoreNodePoint[];
|
mapPoints: MeshCoreNodePoint[];
|
||||||
streamReady: boolean;
|
streamReady: boolean;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { bytesToHex } from '@noble/hashes/utils.js';
|
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||||
import BrandingWatermarkIcon from '@mui/icons-material/BrandingWatermark';
|
import BrandingWatermarkIcon from '@mui/icons-material/BrandingWatermark';
|
||||||
|
import LeakAddIcon from '@mui/icons-material/LeakAdd';
|
||||||
import PersonIcon from '@mui/icons-material/Person';
|
import PersonIcon from '@mui/icons-material/Person';
|
||||||
import QuestionMarkIcon from '@mui/icons-material/QuestionMark';
|
import QuestionMarkIcon from '@mui/icons-material/QuestionMark';
|
||||||
import ReplyIcon from '@mui/icons-material/Reply';
|
import ReplyIcon from '@mui/icons-material/Reply';
|
||||||
@@ -10,20 +10,18 @@ import RouteIcon from '@mui/icons-material/Route';
|
|||||||
import SensorsIcon from '@mui/icons-material/Sensors';
|
import SensorsIcon from '@mui/icons-material/Sensors';
|
||||||
import SignalCellularAltIcon from '@mui/icons-material/SignalCellularAlt';
|
import SignalCellularAltIcon from '@mui/icons-material/SignalCellularAlt';
|
||||||
import StorageIcon from '@mui/icons-material/Storage';
|
import StorageIcon from '@mui/icons-material/Storage';
|
||||||
|
import WifiTetheringIcon from '@mui/icons-material/WifiTethering';
|
||||||
|
|
||||||
import { Packet } from '../../protocols/meshcore';
|
import { Packet } from '../../protocols/meshcore';
|
||||||
import { NodeType, PayloadType, RouteType } from '../../types/protocol/meshcore.types';
|
import { NodeType, PayloadType, RouteType } from '../../types/protocol/meshcore.types';
|
||||||
import { MeshCoreStream } from '../../services/MeshCoreStream';
|
|
||||||
import type { MeshCoreMessage } from '../../services/MeshCoreStream';
|
import type { MeshCoreMessage } from '../../services/MeshCoreStream';
|
||||||
import type { Payload, AdvertPayload } from '../../types/protocol/meshcore.types';
|
|
||||||
import API from '../../services/API';
|
import API from '../../services/API';
|
||||||
import MeshCoreServiceImpl from '../../services/MeshCoreService';
|
import MeshCoreService from '../../services/MeshCoreService';
|
||||||
import { base64ToBytes } from '../../util';
|
import MeshCoreStream from '../../services/MeshCoreStream';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MeshCoreDataContext,
|
MeshCoreDataContext,
|
||||||
type MeshCoreDataContextValue,
|
type MeshCoreDataContextValue,
|
||||||
type MeshCorePacketRecord,
|
|
||||||
type MeshCoreGroupChatRecord,
|
type MeshCoreGroupChatRecord,
|
||||||
type MeshCoreNodePoint,
|
type MeshCoreNodePoint,
|
||||||
} from './MeshCoreContext';
|
} from './MeshCoreContext';
|
||||||
@@ -32,7 +30,6 @@ export {
|
|||||||
MeshCoreDataContext,
|
MeshCoreDataContext,
|
||||||
useMeshCoreData,
|
useMeshCoreData,
|
||||||
type MeshCoreDataContextValue,
|
type MeshCoreDataContextValue,
|
||||||
type MeshCorePacketRecord,
|
|
||||||
type MeshCoreGroupChatRecord,
|
type MeshCoreGroupChatRecord,
|
||||||
type MeshCoreNodePoint,
|
type MeshCoreNodePoint,
|
||||||
} from './MeshCoreContext';
|
} from './MeshCoreContext';
|
||||||
@@ -68,6 +65,21 @@ export const routeTypeNameByValue: Record<number, string> = {
|
|||||||
[RouteType.TRANSPORT_DIRECT]: 'Transport Direct',
|
[RouteType.TRANSPORT_DIRECT]: 'Transport Direct',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const RouteTypeIcon: React.FC<{ routeType: number }> = ({ routeType }) => {
|
||||||
|
switch (routeType) {
|
||||||
|
case RouteType.TRANSPORT_FLOOD:
|
||||||
|
return <LeakAddIcon className="meshcore-route-icon meshcore-route-flood" titleAccess={routeTypeNameByValue[routeType]} />;
|
||||||
|
case RouteType.FLOOD:
|
||||||
|
return <LeakAddIcon className="meshcore-route-icon meshcore-route-flood" titleAccess={routeTypeNameByValue[routeType]} />;
|
||||||
|
case RouteType.DIRECT:
|
||||||
|
return <WifiTetheringIcon className="meshcore-route-icon meshcore-route-direct" titleAccess={routeTypeNameByValue[routeType]} />;
|
||||||
|
case RouteType.TRANSPORT_DIRECT:
|
||||||
|
return <WifiTetheringIcon className="meshcore-route-icon meshcore-route-direct" titleAccess={routeTypeNameByValue[routeType]} />;
|
||||||
|
default:
|
||||||
|
return <QuestionMarkIcon className="meshcore-route-icon" titleAccess="Unknown" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const payloadValueByName = Object.fromEntries(
|
export const payloadValueByName = Object.fromEntries(
|
||||||
Object.entries(PayloadType).map(([name, value]) => [name, value])
|
Object.entries(PayloadType).map(([name, value]) => [name, value])
|
||||||
) as Record<string, number>;
|
) as Record<string, number>;
|
||||||
@@ -104,6 +116,7 @@ export const PayloadTypeIcon: React.FC<{ payloadType: number }> = ({ payloadType
|
|||||||
return <QuestionMarkIcon className="meshcore-payload-icon" titleAccess="Unknown" />;
|
return <QuestionMarkIcon className="meshcore-payload-icon" titleAccess="Unknown" />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const nodeTypeValueByName = Object.fromEntries(
|
export const nodeTypeValueByName = Object.fromEntries(
|
||||||
Object.entries(NodeType).map(([name, value]) => [name, value])
|
Object.entries(NodeType).map(([name, value]) => [name, value])
|
||||||
) as Record<string, number>;
|
) as Record<string, number>;
|
||||||
@@ -182,122 +195,59 @@ export const routeValueByUrl: Record<string, number> = Object.fromEntries(
|
|||||||
|
|
||||||
const DISCARD_DUPLICATE_PATH_PACKETS = true;
|
const DISCARD_DUPLICATE_PATH_PACKETS = true;
|
||||||
|
|
||||||
const getPacketPathKey = (raw: Uint8Array): string => {
|
const dedupeByHashAndPath = (packets: Packet[]): Packet[] => {
|
||||||
if (raw.length < 2) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const pathField = raw[1];
|
|
||||||
const hashSize = (pathField >> 6) + 1;
|
|
||||||
const hashCount = pathField & 0x3f;
|
|
||||||
|
|
||||||
if (hashCount === 0 || hashSize === 4) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const pathByteLength = hashCount * hashSize;
|
|
||||||
const availablePathBytes = Math.min(pathByteLength, Math.max(raw.length - 2, 0));
|
|
||||||
if (availablePathBytes <= 0) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return bytesToHex(raw.slice(2, 2 + availablePathBytes));
|
|
||||||
};
|
|
||||||
|
|
||||||
const dedupeByHashAndPath = (packets: MeshCorePacketRecord[]): MeshCorePacketRecord[] => {
|
|
||||||
if (!DISCARD_DUPLICATE_PATH_PACKETS) {
|
if (!DISCARD_DUPLICATE_PATH_PACKETS) {
|
||||||
return packets;
|
return packets;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedByReceiveTime = [...packets].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
const sortedByReceiveTime = [...packets].sort((a, b) => a.receivedAt.getTime() - b.receivedAt.getTime());
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const deduped: MeshCorePacketRecord[] = [];
|
const deduped: Packet[] = [];
|
||||||
|
|
||||||
sortedByReceiveTime.forEach((packet) => {
|
sortedByReceiveTime.forEach((packet) => {
|
||||||
const signature = `${packet.hash}:${getPacketPathKey(packet.raw)}`;
|
const hash = packet.hash();
|
||||||
if (seen.has(signature)) {
|
if (seen.has(hash)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
seen.add(signature);
|
seen.add(hash);
|
||||||
deduped.push(packet);
|
deduped.push(packet);
|
||||||
});
|
});
|
||||||
|
|
||||||
return deduped;
|
return deduped;
|
||||||
};
|
};
|
||||||
|
|
||||||
const summarizePayload = (payloadType: number, decodedPayload: Payload | undefined, payloadBytes: Uint8Array): string => {
|
// `payloadSummary` and its summarization logic were removed — summaries are rendered
|
||||||
switch (payloadType) {
|
// directly in the packet rows now. Keep path/key helpers above.
|
||||||
case PayloadType.REQUEST:
|
|
||||||
return `request len=${payloadBytes.length}`;
|
|
||||||
case PayloadType.RESPONSE:
|
|
||||||
return `response len=${payloadBytes.length}`;
|
|
||||||
case PayloadType.TEXT:
|
|
||||||
if (decodedPayload && 'dstHash' in decodedPayload && 'srcHash' in decodedPayload) {
|
|
||||||
return `text ${decodedPayload.srcHash}->${decodedPayload.dstHash}`;
|
|
||||||
}
|
|
||||||
return 'text message';
|
|
||||||
case PayloadType.ACK:
|
|
||||||
return 'acknowledgement';
|
|
||||||
case PayloadType.ADVERT:
|
|
||||||
const advert = decodedPayload as AdvertPayload;
|
|
||||||
console.log('advert', advert);
|
|
||||||
if (advert && decodedPayload && 'appdata' in decodedPayload && advert.appdata && 'name' in advert.appdata) {
|
|
||||||
return `advertisement: ${advert.appdata.name}`;
|
|
||||||
}
|
|
||||||
return 'advertisement';
|
|
||||||
case PayloadType.GROUP_TEXT:
|
|
||||||
if (decodedPayload && 'channelHash' in decodedPayload) {
|
|
||||||
return `group channel=${decodedPayload.channelHash}`;
|
|
||||||
}
|
|
||||||
return `group text len=${payloadBytes.length}`;
|
|
||||||
case PayloadType.GROUP_DATA:
|
|
||||||
return `group data len=${payloadBytes.length}`;
|
|
||||||
case PayloadType.ANON_REQ:
|
|
||||||
if (decodedPayload && 'dstHash' in decodedPayload) {
|
|
||||||
return `anon req dst=${decodedPayload.dstHash}`;
|
|
||||||
}
|
|
||||||
return 'anon request';
|
|
||||||
case PayloadType.PATH:
|
|
||||||
return `path len=${payloadBytes.length}`;
|
|
||||||
case PayloadType.TRACE:
|
|
||||||
return `trace len=${payloadBytes.length}`;
|
|
||||||
case PayloadType.MULTIPART:
|
|
||||||
return `multipart len=${payloadBytes.length}`;
|
|
||||||
case PayloadType.CONTROL:
|
|
||||||
if (decodedPayload && 'flags' in decodedPayload && typeof decodedPayload.flags === 'number') {
|
|
||||||
return `control flags=0x${decodedPayload.flags.toString(16)}`;
|
|
||||||
}
|
|
||||||
return `control raw=${bytesToHex(payloadBytes.slice(0, 4))}`;
|
|
||||||
case PayloadType.RAW_CUSTOM:
|
|
||||||
default:
|
|
||||||
return `raw=${bytesToHex(payloadBytes.slice(0, Math.min(6, payloadBytes.length)))}`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toGroupChats = (packets: MeshCorePacketRecord[]): MeshCoreGroupChatRecord[] => {
|
const toGroupChats = (packets: Packet[]): MeshCoreGroupChatRecord[] => {
|
||||||
return packets
|
return packets
|
||||||
.filter((packet) => packet.payloadType === PayloadType.GROUP_TEXT || packet.payloadType === PayloadType.TEXT)
|
.filter((packet) => packet.payloadType === PayloadType.GROUP_TEXT || packet.payloadType === PayloadType.TEXT)
|
||||||
.map((packet, index) => {
|
.map((packet, index) => {
|
||||||
const payload = packet.decodedPayload as Record<string, unknown> | undefined;
|
const payload = packet.decodedPayload as Record<string, unknown>;
|
||||||
const channel = (payload && typeof payload === 'object' && 'channelHash' in payload
|
const channel = (payload && typeof payload === 'object' && 'channelHash' in payload
|
||||||
? (payload.channelHash as string)
|
? (payload.channelHash as string)
|
||||||
: 'general') as string;
|
: 'general') as string;
|
||||||
const sender = (payload && typeof payload === 'object' && 'srcHash' in payload
|
const sender = (payload && typeof payload === 'object' && 'srcHash' in payload
|
||||||
? (payload.srcHash as string)
|
? (payload.srcHash as string)
|
||||||
: packet.path[0].toString(16).padStart(2, '0')) as string;
|
: (packet.path.length > 0 ? packet.path[0].toString(16).padStart(2, '0') : 'unknown')) as string;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hash: packet.hash,
|
hash: packet.hash,
|
||||||
timestamp: packet.timestamp,
|
timestamp: packet.timestamp,
|
||||||
channel,
|
channel,
|
||||||
sender,
|
sender,
|
||||||
message: `Mock message #${index + 1} (${packet.payloadSummary})`,
|
message: (() => {
|
||||||
|
const msg = (payload && typeof payload === 'object')
|
||||||
|
? ('message' in payload ? String((payload as any).message) : ('text' in payload ? String((payload as any).text) : undefined))
|
||||||
|
: undefined;
|
||||||
|
return msg ?? `Mock message #${index + 1}`;
|
||||||
|
})(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const toMapPoints = (packets: MeshCorePacketRecord[]): MeshCoreNodePoint[] => {
|
const toMapPoints = (packets: Packet[]): MeshCoreNodePoint[] => {
|
||||||
const byNode = new Map<string, MeshCoreNodePoint>();
|
const byNode = new Map<string, MeshCoreNodePoint>();
|
||||||
|
|
||||||
packets.forEach((packet) => {
|
packets.forEach((packet) => {
|
||||||
@@ -327,57 +277,47 @@ const toMapPoints = (packets: MeshCorePacketRecord[]): MeshCoreNodePoint[] => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const MeshCoreDataProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const MeshCoreDataProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const [packets, setPackets] = useState<MeshCorePacketRecord[]>([]);
|
const [packets, setPackets] = useState<Packet[]>([]);
|
||||||
const [streamReady, setStreamReady] = useState(false);
|
const [streamReady, setStreamReady] = useState(false);
|
||||||
|
|
||||||
const stream = useMemo(() => new MeshCoreStream(false), []);
|
const stream = useMemo(() => new MeshCoreStream(false), []);
|
||||||
const meshCoreService = useMemo(() => new MeshCoreServiceImpl(API), []);
|
const meshCoreService = useMemo(() => new MeshCoreService(API), []);
|
||||||
|
|
||||||
|
// Populate stream key manager with known groups so live decryption works
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const groups = await meshCoreService.fetchGroups();
|
||||||
|
if (!mounted) return;
|
||||||
|
for (const g of groups) {
|
||||||
|
try {
|
||||||
|
stream.keyManager.addGroup(g.name, g.secret.toBytes());
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { mounted = false; };
|
||||||
|
}, [meshCoreService, stream]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
const fetchPackets = async () => {
|
const fetchPackets = async () => {
|
||||||
try {
|
try {
|
||||||
const fetchedPackets = await meshCoreService.fetchPackets();
|
const fetchedPackets = await meshCoreService.fetchPackets(undefined, undefined, undefined, stream.keyManager);
|
||||||
if (!isMounted) {
|
if (!isMounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const records: MeshCorePacketRecord[] = fetchedPackets.map((packet) => {
|
|
||||||
const raw = base64ToBytes(packet.raw);
|
|
||||||
|
|
||||||
let decodedPayload: Payload | undefined;
|
|
||||||
try {
|
|
||||||
const p = new Packet();
|
|
||||||
p.parse(raw);
|
|
||||||
decodedPayload = p.decode();
|
|
||||||
} catch {
|
|
||||||
decodedPayload = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pathLength = raw[1] & 0x3f;
|
|
||||||
const payloadBytes = raw.slice(2 + pathLength);
|
|
||||||
|
|
||||||
return {
|
|
||||||
timestamp: new Date(packet.received_at),
|
|
||||||
hash: packet.hash,
|
|
||||||
nodeType: packet.payload_type === PayloadType.ADVERT ? NodeType.TYPE_UNKNOWN : 0,
|
|
||||||
payloadType: packet.payload_type,
|
|
||||||
routeType: packet.route_type,
|
|
||||||
version: packet.version,
|
|
||||||
path: raw.slice(2, 2 + pathLength),
|
|
||||||
raw,
|
|
||||||
decodedPayload,
|
|
||||||
payloadSummary: summarizePayload(packet.payload_type, decodedPayload, payloadBytes),
|
|
||||||
snr: packet.snr,
|
|
||||||
rssi: packet.rssi,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
setPackets((prev) => {
|
setPackets((prev) => {
|
||||||
const merged = dedupeByHashAndPath([...records, ...prev]);
|
const merged = dedupeByHashAndPath([...fetchedPackets, ...prev]);
|
||||||
return merged
|
return merged
|
||||||
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
|
.sort((a, b) => b.receivedAt.getTime() - a.receivedAt.getTime())
|
||||||
.slice(0, 500);
|
.slice(0, 500);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -396,7 +336,7 @@ export const MeshCoreDataProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
'meshcore/packet/#',
|
'meshcore/packet/#',
|
||||||
(message) => {
|
(message) => {
|
||||||
setPackets((prev) => {
|
setPackets((prev) => {
|
||||||
const packet: MeshCorePacketRecord = {
|
const packet: Packet = {
|
||||||
timestamp: message.receivedAt,
|
timestamp: message.receivedAt,
|
||||||
hash: message.hash,
|
hash: message.hash,
|
||||||
nodeType: 0, // Default; would be extracted from payload if needed
|
nodeType: 0, // Default; would be extracted from payload if needed
|
||||||
@@ -406,7 +346,6 @@ export const MeshCoreDataProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
path: new Uint8Array(),
|
path: new Uint8Array(),
|
||||||
raw: message.raw,
|
raw: message.raw,
|
||||||
decodedPayload: message.decodedPayload,
|
decodedPayload: message.decodedPayload,
|
||||||
payloadSummary: '',
|
|
||||||
radioName: message.radioName,
|
radioName: message.radioName,
|
||||||
snr: message.snr,
|
snr: message.snr,
|
||||||
rssi: message.rssi,
|
rssi: message.rssi,
|
||||||
@@ -423,12 +362,12 @@ export const MeshCoreDataProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
const pathLength = message.raw[1] & 0x3f;
|
const pathLength = message.raw[1] & 0x3f;
|
||||||
packet.path = message.raw.slice(2, 2 + pathLength);
|
packet.path = message.raw.slice(2, 2 + pathLength);
|
||||||
|
|
||||||
// Summarize payload
|
// If the stream provided a decrypted group message, attach it to the record
|
||||||
const payloadBytes = message.raw.slice(2 + pathLength);
|
if (message.decryptedGroup) {
|
||||||
packet.payloadSummary = summarizePayload(packet.payloadType, message.decodedPayload, payloadBytes);
|
packet.decryptedGroup = message.decryptedGroup;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to parse packet:', error);
|
console.error('Failed to parse packet:', error);
|
||||||
packet.payloadSummary = `raw=${bytesToHex(packet.raw.slice(0, Math.min(6, packet.raw.length)))}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const merged = dedupeByHashAndPath([packet, ...prev]);
|
const merged = dedupeByHashAndPath([packet, ...prev]);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState, useMemo, useRef } from 'react';
|
import React, { useEffect, useState, useMemo, useRef } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
import { Badge, Card, Stack, Spinner, Alert } from 'react-bootstrap';
|
import { Badge, Card, Stack, Spinner, Alert } from 'react-bootstrap';
|
||||||
import { useMeshCoreData } from './MeshCoreContext';
|
import { useMeshCoreData } from './MeshCoreContext';
|
||||||
import VerticalSplit from '../../components/VerticalSplit';
|
import VerticalSplit from '../../components/VerticalSplit';
|
||||||
@@ -6,7 +7,7 @@ import StreamStatus from '../../components/StreamStatus';
|
|||||||
import MeshCoreServiceImpl, { type MeshCoreGroupRecord } from '../../services/MeshCoreService';
|
import MeshCoreServiceImpl, { type MeshCoreGroupRecord } from '../../services/MeshCoreService';
|
||||||
import API from '../../services/API';
|
import API from '../../services/API';
|
||||||
import type { MeshCoreGroupChatRecord } from './MeshCoreContext';
|
import type { MeshCoreGroupChatRecord } from './MeshCoreContext';
|
||||||
import { KeyManager, Packet } from '../../protocols/meshcore';
|
import { KeyManager, Packet, GroupSecret } from '../../protocols/meshcore';
|
||||||
import KeyboardShortcutsModal from '../../components/KeyboardShortcutsModal';
|
import KeyboardShortcutsModal from '../../components/KeyboardShortcutsModal';
|
||||||
import { useKeyboardListNavigation } from '../../hooks/useKeyboardListNavigation';
|
import { useKeyboardListNavigation } from '../../hooks/useKeyboardListNavigation';
|
||||||
|
|
||||||
@@ -73,7 +74,6 @@ const GroupList: React.FC<{
|
|||||||
>
|
>
|
||||||
<div className="list-item-title">{group.name}</div>
|
<div className="list-item-title">{group.name}</div>
|
||||||
<div className="list-item-meta">
|
<div className="list-item-meta">
|
||||||
<small>ID: {group.id}</small>
|
|
||||||
{group.isPublic && <Badge bg="info" className="ms-2">public</Badge>}
|
{group.isPublic && <Badge bg="info" className="ms-2">public</Badge>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,16 +120,15 @@ const GroupMessagesPane: React.FC<{
|
|||||||
<div className="p-3 text-secondary text-center">No messages in this group</div>
|
<div className="p-3 text-secondary text-center">No messages in this group</div>
|
||||||
)}
|
)}
|
||||||
<div className="data-table-scroll">
|
<div className="data-table-scroll">
|
||||||
<Stack gap={3} className="p-3">
|
<Stack gap={1} className="p-3">
|
||||||
{messages.map((message) => (
|
{messages.map((message, index) => (
|
||||||
<div key={message.hash + message.timestamp.toISOString()} className="meshcore-message-item">
|
<div key={message.hash + message.timestamp.toISOString() + index.toString()} className="meshcore-message-item">
|
||||||
<div className="d-flex justify-content-between align-items-center mb-1">
|
<div className="d-flex justify-content-between align-items-center mb-1">
|
||||||
<strong className="meshcore-message-sender">{message.sender}</strong>
|
<strong className="meshcore-message-sender">{message.sender}</strong>
|
||||||
<small className="text-secondary">{message.timestamp.toLocaleTimeString()}</small>
|
<small className="text-secondary">{message.timestamp.toLocaleTimeString()}</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="meshcore-message-text mb-2">{message.message}</div>
|
|
||||||
<div className="d-flex justify-content-between align-items-center">
|
<div className="d-flex justify-content-between align-items-center">
|
||||||
<Badge bg="primary">#{message.channel}</Badge>
|
<div className="meshcore-message-text mb-2">{message.message}</div>
|
||||||
<code className="meshcore-hash-code">{message.hash}</code>
|
<code className="meshcore-hash-code">{message.hash}</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,6 +142,7 @@ const GroupMessagesPane: React.FC<{
|
|||||||
|
|
||||||
const MeshCoreGroupChatView: React.FC = () => {
|
const MeshCoreGroupChatView: React.FC = () => {
|
||||||
const { groupChats, streamReady } = useMeshCoreData();
|
const { groupChats, streamReady } = useMeshCoreData();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [groups, setGroups] = useState<MeshCoreGroupRecord[]>([]);
|
const [groups, setGroups] = useState<MeshCoreGroupRecord[]>([]);
|
||||||
const [selectedGroup, setSelectedGroup] = useState<MeshCoreGroupRecord | null>(null);
|
const [selectedGroup, setSelectedGroup] = useState<MeshCoreGroupRecord | null>(null);
|
||||||
const [isLoadingGroups, setIsLoadingGroups] = useState(false);
|
const [isLoadingGroups, setIsLoadingGroups] = useState(false);
|
||||||
@@ -151,6 +151,7 @@ const MeshCoreGroupChatView: React.FC = () => {
|
|||||||
const [packetsError, setPacketsError] = useState<string | null>(null);
|
const [packetsError, setPacketsError] = useState<string | null>(null);
|
||||||
const [decryptedMessages, setDecryptedMessages] = useState<MeshCoreGroupChatRecord[]>([]);
|
const [decryptedMessages, setDecryptedMessages] = useState<MeshCoreGroupChatRecord[]>([]);
|
||||||
const keyManagerRef = useRef<KeyManager>(new KeyManager());
|
const keyManagerRef = useRef<KeyManager>(new KeyManager());
|
||||||
|
const initialChannel = searchParams.get('channel');
|
||||||
|
|
||||||
// Fetch groups on mount
|
// Fetch groups on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -159,7 +160,6 @@ const MeshCoreGroupChatView: React.FC = () => {
|
|||||||
setGroupsError(null);
|
setGroupsError(null);
|
||||||
try {
|
try {
|
||||||
const fetchedGroups = await meshCoreService.fetchGroups();
|
const fetchedGroups = await meshCoreService.fetchGroups();
|
||||||
setGroups(fetchedGroups);
|
|
||||||
|
|
||||||
// Add groups to key manager
|
// Add groups to key manager
|
||||||
const keyManager = keyManagerRef.current;
|
const keyManager = keyManagerRef.current;
|
||||||
@@ -171,8 +171,71 @@ const MeshCoreGroupChatView: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fetchedGroups.length > 0 && !selectedGroup) {
|
// Sort groups: "Public" (case-insensitive) first, then groups
|
||||||
setSelectedGroup(fetchedGroups[0]);
|
// that do NOT start with '#', then groups starting with '#'.
|
||||||
|
// Each bucket is sorted alphabetically by name.
|
||||||
|
const byName = (a: MeshCoreGroupRecord, b: MeshCoreGroupRecord) =>
|
||||||
|
a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
||||||
|
|
||||||
|
const isPublicName = (g: MeshCoreGroupRecord) => (g.name || '').toLowerCase() === 'public';
|
||||||
|
const startsWithHash = (g: MeshCoreGroupRecord) => (g.name || '').startsWith('#');
|
||||||
|
|
||||||
|
const publicGroups = fetchedGroups.filter(isPublicName).sort(byName);
|
||||||
|
const others = fetchedGroups.filter((g) => !isPublicName(g));
|
||||||
|
const hashGroups = others.filter(startsWithHash).sort(byName);
|
||||||
|
const normalGroups = others.filter((g) => !startsWithHash(g)).sort(byName);
|
||||||
|
|
||||||
|
let sorted = [...publicGroups, ...normalGroups, ...hashGroups];
|
||||||
|
|
||||||
|
// If URL contains a channel param, try to select it. If it's a hashtag
|
||||||
|
// channel we don't know yet, add it to the key manager and include it in the list.
|
||||||
|
try {
|
||||||
|
const channelParam = initialChannel;
|
||||||
|
if (channelParam) {
|
||||||
|
let found = sorted.find((g) => g.name === channelParam || g.secret.toHash() === channelParam);
|
||||||
|
if (!found && channelParam.startsWith('#')) {
|
||||||
|
// Add to key manager (will derive secret from name) and include in list
|
||||||
|
try {
|
||||||
|
keyManager.addGroup(channelParam);
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
const newGroup: MeshCoreGroupRecord = {
|
||||||
|
id: Date.now() * -1,
|
||||||
|
name: channelParam,
|
||||||
|
secret: GroupSecret.fromName(channelParam),
|
||||||
|
isPublic: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const combined = [...fetchedGroups, newGroup];
|
||||||
|
const pub = combined.filter(isPublicName).sort(byName);
|
||||||
|
const oth = combined.filter((g) => !isPublicName(g));
|
||||||
|
const hash = oth.filter(startsWithHash).sort(byName);
|
||||||
|
const normal = oth.filter((g) => !startsWithHash(g)).sort(byName);
|
||||||
|
sorted = [...pub, ...normal, ...hash];
|
||||||
|
|
||||||
|
found = sorted.find((g) => g.name === channelParam) || newGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGroups(sorted);
|
||||||
|
|
||||||
|
if (found && !selectedGroup) {
|
||||||
|
setSelectedGroup(found);
|
||||||
|
} else if (!found && sorted.length > 0 && !selectedGroup) {
|
||||||
|
setSelectedGroup(sorted[0]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setGroups(sorted);
|
||||||
|
if (sorted.length > 0 && !selectedGroup) {
|
||||||
|
setSelectedGroup(sorted[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setGroups(sorted);
|
||||||
|
if (sorted.length > 0 && !selectedGroup) {
|
||||||
|
setSelectedGroup(sorted[0]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setGroupsError(err instanceof Error ? err.message : 'Failed to load groups');
|
setGroupsError(err instanceof Error ? err.message : 'Failed to load groups');
|
||||||
@@ -205,9 +268,10 @@ const MeshCoreGroupChatView: React.FC = () => {
|
|||||||
const payload = p.decode();
|
const payload = p.decode();
|
||||||
|
|
||||||
if (payload && 'cipherText' in payload && 'cipherMAC' in payload && 'channelHash' in payload) {
|
if (payload && 'cipherText' in payload && 'cipherMAC' in payload && 'channelHash' in payload) {
|
||||||
|
try {
|
||||||
const decrypted = keyManagerRef.current.decryptGroup(
|
const decrypted = keyManagerRef.current.decryptGroup(
|
||||||
packet.channel_hash,
|
payload.channelHash,
|
||||||
payload.cipherText as Uint8Array,
|
payload.cipherText,
|
||||||
payload.cipherMAC
|
payload.cipherMAC
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -218,9 +282,13 @@ const MeshCoreGroupChatView: React.FC = () => {
|
|||||||
sender: decrypted.message.split(':')[0] || 'Unknown',
|
sender: decrypted.message.split(':')[0] || 'Unknown',
|
||||||
message: decrypted.message.split(':').slice(1).join(':').trim(),
|
message: decrypted.message.split(':').slice(1).join(':').trim(),
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Decryption may fail for packets we don't have keys for — ignore silently.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to decrypt packet:', err);
|
console.warn('Failed to parse packet:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,9 +313,13 @@ const MeshCoreGroupChatView: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Combine with decrypted messages and deduplicate by hash
|
// Combine with decrypted messages and deduplicate by hash
|
||||||
const combined = [...decryptedMessages, ...streamMessages];
|
// Prefer decrypted messages when present; fall back to stream messages otherwise.
|
||||||
|
if (decryptedMessages.length > 0) {
|
||||||
|
return decryptedMessages.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||||
|
}
|
||||||
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const unique = combined.filter((msg) => {
|
const unique = streamMessages.filter((msg) => {
|
||||||
if (seen.has(msg.hash)) return false;
|
if (seen.has(msg.hash)) return false;
|
||||||
seen.add(msg.hash);
|
seen.add(msg.hash);
|
||||||
return true;
|
return true;
|
||||||
@@ -258,6 +330,14 @@ const MeshCoreGroupChatView: React.FC = () => {
|
|||||||
|
|
||||||
const handleSelectGroup = (group: MeshCoreGroupRecord) => {
|
const handleSelectGroup = (group: MeshCoreGroupRecord) => {
|
||||||
setSelectedGroup(group);
|
setSelectedGroup(group);
|
||||||
|
try {
|
||||||
|
setSearchParams((sp) => {
|
||||||
|
sp.set('channel', group.name);
|
||||||
|
return sp;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
437
ui/src/pages/meshcore/MeshCoreNodesView.tsx
Normal file
437
ui/src/pages/meshcore/MeshCoreNodesView.tsx
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
import React, { useEffect, useMemo, useState, useRef } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
import { Card, Table, Spinner, Alert, Pagination, Form, Dropdown } from 'react-bootstrap';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
|
||||||
|
import API from '../../services/API';
|
||||||
|
import MeshCoreService from '../../services/MeshCoreService';
|
||||||
|
import VerticalSplit from '../../components/VerticalSplit';
|
||||||
|
import { useKeyboardListNavigation } from '../../hooks/useKeyboardListNavigation';
|
||||||
|
import TimeAgo from '../../components/TimeAgo';
|
||||||
|
|
||||||
|
const meshCoreService = new MeshCoreService(API);
|
||||||
|
|
||||||
|
export const MeshCoreNodesView: React.FC = () => {
|
||||||
|
const NODE_TYPES: { value: string; label: string }[] = [
|
||||||
|
{ value: '', label: 'All' },
|
||||||
|
{ value: 'repeater', label: 'Repeater' },
|
||||||
|
{ value: 'chat', label: 'Chat' },
|
||||||
|
{ value: 'room', label: 'Room' },
|
||||||
|
{ value: 'sensor', label: 'Sensor' },
|
||||||
|
];
|
||||||
|
const [page, setPage] = useState<number>(1);
|
||||||
|
const [visibleRows, setVisibleRows] = useState<number>(50);
|
||||||
|
const [fetchLimit] = useState<number>(50);
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const tableWrapperRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const paginationRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [pager, setPager] = useState<any | null>(null);
|
||||||
|
const [allNodes, setAllNodes] = useState<any[] | null>(null);
|
||||||
|
const [allLoading, setAllLoading] = useState<boolean>(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||||
|
const [selectedType, setSelectedType] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState<string>('');
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedNode, setSelectedNode] = useState<any | null>(null);
|
||||||
|
|
||||||
|
const renderHighlighted = (text: string, query: string | null) => {
|
||||||
|
if (!query) return <>{text}</>;
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return <>{text}</>;
|
||||||
|
const txt = text || '';
|
||||||
|
const lower = txt.toLowerCase();
|
||||||
|
if (!lower.includes(q)) return <>{text}</>;
|
||||||
|
const parts: React.ReactNode[] = [];
|
||||||
|
let start = 0;
|
||||||
|
let idx = 0;
|
||||||
|
while ((idx = lower.indexOf(q, start)) !== -1) {
|
||||||
|
if (idx > start) parts.push(txt.slice(start, idx));
|
||||||
|
parts.push(<u key={start + idx}>{txt.slice(idx, idx + q.length)}</u>);
|
||||||
|
start = idx + q.length;
|
||||||
|
}
|
||||||
|
if (start < txt.length) parts.push(txt.slice(start));
|
||||||
|
return <>{parts}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPubKey = (k: string | undefined) => {
|
||||||
|
if (!k) return '';
|
||||||
|
const s = String(k);
|
||||||
|
if (s.length <= 16) return s;
|
||||||
|
return `${s.slice(0, 8)}…${s.slice(-8)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
const load = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// Map the visual `page` (which is based on `visibleRows`) to the server page
|
||||||
|
const serverPage = Math.max(1, Math.ceil((page * visibleRows) / fetchLimit));
|
||||||
|
const p = await meshCoreService.fetchNodes(serverPage, fetchLimit, selectedType ?? undefined);
|
||||||
|
if (!isMounted) return;
|
||||||
|
// Ensure items is an array (server may return null) and sort alphabetically by name
|
||||||
|
p.items = Array.isArray(p.items) ? p.items : [];
|
||||||
|
p.items.sort((a: any, b: any) => (a.name || '').localeCompare(b.name || ''));
|
||||||
|
setPager(p);
|
||||||
|
if (!selectedNode && p.items.length > 0) {
|
||||||
|
setSelectedNode(p.items[0]);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!isMounted) return;
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load nodes');
|
||||||
|
} finally {
|
||||||
|
if (isMounted) setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void load();
|
||||||
|
return () => { isMounted = false; };
|
||||||
|
}, [page, fetchLimit, visibleRows, selectedType]);
|
||||||
|
// sync search params for ?type=
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = searchParams.get('type');
|
||||||
|
setSelectedType(t);
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pager || allNodes !== null || allLoading) return;
|
||||||
|
let isMounted = true;
|
||||||
|
const loadAllSerial = async () => {
|
||||||
|
setAllLoading(true);
|
||||||
|
try {
|
||||||
|
const total = pager.total || 0;
|
||||||
|
if (total === 0) {
|
||||||
|
if (isMounted) setAllNodes([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use server-provided limit when available to compute pages
|
||||||
|
const pageSize = Math.max(1, pager.limit || fetchLimit || 50);
|
||||||
|
const pages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
|
||||||
|
|
||||||
|
// Start with first page items returned by initial fetch
|
||||||
|
const items: any[] = Array.isArray(pager.items) ? pager.items.slice() : [];
|
||||||
|
|
||||||
|
for (let p = 2; p <= pages; p++) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
// serial await ensures requests are not concurrent
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const res = await meshCoreService.fetchNodes(p, pageSize, selectedType ?? undefined);
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
if (Array.isArray(res.items)) items.push(...res.items);
|
||||||
|
} catch (err) {
|
||||||
|
|
||||||
|
// continue to next page on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
// dedupe by id and sort alphabetically by name
|
||||||
|
const byId: Record<string, any> = {};
|
||||||
|
for (const it of items) {
|
||||||
|
if (!it) continue;
|
||||||
|
byId[String(it.id)] = it;
|
||||||
|
}
|
||||||
|
const unique = Object.values(byId);
|
||||||
|
unique.sort((a: any, b: any) => (a.name || '').localeCompare(b.name || ''));
|
||||||
|
|
||||||
|
if (isMounted) setAllNodes(unique);
|
||||||
|
} catch (err) {
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
if (isMounted) setAllLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadAllSerial();
|
||||||
|
return () => { isMounted = false; };
|
||||||
|
}, [pager, fetchLimit, selectedType]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Table sizing by visual viewport removed — allow vertical overflow again.
|
||||||
|
}, [pager]);
|
||||||
|
|
||||||
|
const totalPages = useMemo(() => {
|
||||||
|
if (allNodes) return Math.max(1, Math.ceil(allNodes.length / visibleRows));
|
||||||
|
if (!pager) return 0;
|
||||||
|
return Math.max(1, Math.ceil(pager.total / pager.limit));
|
||||||
|
}, [pager, allNodes, visibleRows]);
|
||||||
|
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
const nodes = allNodes ?? (pager ? pager.items : []);
|
||||||
|
if (!search) return nodes;
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
|
||||||
|
return (nodes || []).filter((n: any) => {
|
||||||
|
const name = (n.name || '').toLowerCase();
|
||||||
|
const idOrPrefix = (n.prefix || n.id || '').toString().toLowerCase();
|
||||||
|
const pub = (n.public_key || '').toString().toLowerCase();
|
||||||
|
|
||||||
|
const matchName = name.includes(q);
|
||||||
|
const matchId = idOrPrefix.includes(q);
|
||||||
|
const matchPub = pub.startsWith(q); // only match prefix of public key
|
||||||
|
|
||||||
|
return matchName || matchId || matchPub;
|
||||||
|
});
|
||||||
|
}, [allNodes, pager, search]);
|
||||||
|
|
||||||
|
const currentItems = useMemo(() => {
|
||||||
|
if (allNodes) {
|
||||||
|
return filteredItems.slice((page - 1) * visibleRows, page * visibleRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
// pager-mode: compute slice relative to the last fetched server page
|
||||||
|
if (!pager) return [];
|
||||||
|
const items = Array.isArray(pager.items) ? pager.items : [];
|
||||||
|
const serverPage = pager.page || 1;
|
||||||
|
const serverLimit = pager.limit || fetchLimit;
|
||||||
|
|
||||||
|
// global index range for this visual page
|
||||||
|
const globalStart = (page - 1) * visibleRows;
|
||||||
|
const serverStart = (serverPage - 1) * serverLimit;
|
||||||
|
const offsetWithinServer = globalStart - serverStart;
|
||||||
|
|
||||||
|
if (offsetWithinServer >= items.length || offsetWithinServer + visibleRows <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Math.max(0, offsetWithinServer);
|
||||||
|
return items.slice(start, start + visibleRows);
|
||||||
|
}, [allNodes, filteredItems, pager, page, visibleRows, fetchLimit]);
|
||||||
|
|
||||||
|
// keep selectedIndex in sync with selectedNode when page/items change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedNode) {
|
||||||
|
setSelectedIndex(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const idx = currentItems.findIndex((it: any) => String(it.id) === String(selectedNode.id));
|
||||||
|
setSelectedIndex(idx >= 0 ? idx : null);
|
||||||
|
}, [selectedNode, currentItems]);
|
||||||
|
|
||||||
|
// wire page navigation into the keyboard hook (ArrowLeft/ArrowRight)
|
||||||
|
const onPrevPage = React.useCallback(() => setPage((p) => Math.max(1, p - 1)), [setPage]);
|
||||||
|
const onNextPage = React.useCallback(() => setPage((p) => Math.min(totalPages, p + 1)), [setPage, totalPages]);
|
||||||
|
|
||||||
|
const { /* showShortcuts, setShowShortcuts, */ } = useKeyboardListNavigation({
|
||||||
|
itemCount: currentItems.length,
|
||||||
|
selectedIndex,
|
||||||
|
onSelectIndex: (i: number | null) => {
|
||||||
|
setSelectedIndex(i);
|
||||||
|
if (i === null) {
|
||||||
|
setSelectedNode(null);
|
||||||
|
} else {
|
||||||
|
const it = currentItems[i];
|
||||||
|
if (it) setSelectedNode(it);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scrollContainerRef: tableWrapperRef,
|
||||||
|
rowSelector: '[data-nav-item="true"]',
|
||||||
|
enabled: true,
|
||||||
|
onPrevPage,
|
||||||
|
onNextPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VerticalSplit
|
||||||
|
ratio="3:1"
|
||||||
|
left={(
|
||||||
|
<Card className="data-table-card h-100 d-flex flex-column">
|
||||||
|
<Card.Header className="data-table-header d-flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
MeshCore Nodes
|
||||||
|
<small className="ms-2">({filteredItems.length})</small>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex align-items-center gap-2" style={{ minWidth: 260 }}>
|
||||||
|
<Form.Control
|
||||||
|
placeholder={allNodes ? 'Search by name or hash...' : 'Search will be enabled after all nodes are loaded'}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||||
|
disabled={!allNodes}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
{!allNodes && allLoading && (
|
||||||
|
<div className="text-secondary d-flex align-items-center" style={{ fontSize: 12 }}>
|
||||||
|
<Spinner animation="border" size="sm" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Type filter dropdown moved to header so it's visible even without table items */}
|
||||||
|
<Dropdown className="ms-2">
|
||||||
|
<Dropdown.Toggle as="button" variant="link" bsPrefix="p-0 border-0 bg-transparent" id="type-filter-toggle-header" title="Filter by type">
|
||||||
|
<SearchIcon fontSize="small" />
|
||||||
|
</Dropdown.Toggle>
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Item
|
||||||
|
active={!selectedType}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedType(null);
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
newParams.delete('type');
|
||||||
|
setSearchParams(newParams, { replace: true });
|
||||||
|
setPage(1);
|
||||||
|
setAllNodes(null);
|
||||||
|
setPager(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</Dropdown.Item>
|
||||||
|
{NODE_TYPES.filter((o) => o.value).map((opt) => (
|
||||||
|
<Dropdown.Item
|
||||||
|
key={opt.value}
|
||||||
|
active={selectedType === opt.value}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedType(opt.value);
|
||||||
|
const newParams = new URLSearchParams(searchParams);
|
||||||
|
newParams.set('type', opt.value);
|
||||||
|
setSearchParams(newParams, { replace: true });
|
||||||
|
setPage(1);
|
||||||
|
setAllNodes(null);
|
||||||
|
setPager(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</Dropdown.Item>
|
||||||
|
))}
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body className="data-table-body d-flex flex-column p-0">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="d-flex align-items-center gap-2 p-3 text-secondary">
|
||||||
|
<Spinner animation="border" size="sm" /> Loading nodes...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="danger" className="m-3">{error}</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !error && (pager || allNodes) && (
|
||||||
|
<div ref={containerRef} className="p-2 d-flex flex-column h-100">
|
||||||
|
{/* search moved to header */}
|
||||||
|
<div ref={tableWrapperRef} className="table-responsive mb-2" style={{ overflowY: 'auto' }}>
|
||||||
|
<Table hover size="sm" className="mb-0" style={{ tableLayout: 'fixed' }}>
|
||||||
|
<colgroup>
|
||||||
|
<col />
|
||||||
|
<col style={{ width: '8ch' }} />
|
||||||
|
<col style={{ width: '6ch' }} />
|
||||||
|
<col style={{ width: '14ch' }} />
|
||||||
|
<col style={{ width: '9ch' }} className="text-end" />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Node ID</th>
|
||||||
|
<th>
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<span>Type</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th>Public key</th>
|
||||||
|
<th>Last seen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{currentItems.map((n: any, idx: number) => (
|
||||||
|
<tr
|
||||||
|
key={n.id}
|
||||||
|
data-nav-item="true"
|
||||||
|
onClick={() => { setSelectedNode(n); setSelectedIndex(idx); }}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
className={selectedIndex === idx ? 'table-active' : ''}
|
||||||
|
aria-selected={selectedIndex === idx}
|
||||||
|
>
|
||||||
|
<td>{renderHighlighted(n.name || '(unknown)', search)}</td>
|
||||||
|
<td><span className="meshcore-hash">{renderHighlighted(String(n.prefix || n.id || ''), search)}</span></td>
|
||||||
|
<td>{n.type}</td>
|
||||||
|
<td><span className="meshcore-hash">{renderHighlighted(formatPubKey(n.public_key), search)}</span></td>
|
||||||
|
<td className="text-end"><TimeAgo time={n.last_heard_at} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={paginationRef} className="p-2 mt-auto">
|
||||||
|
<Pagination className="mb-0">
|
||||||
|
<Pagination.First onClick={() => setPage(1)} disabled={page === 1} />
|
||||||
|
<Pagination.Prev onClick={() => setPage(Math.max(1, page - 1))} disabled={page === 1} />
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const items: (number | string)[] = [];
|
||||||
|
if (totalPages <= 7) {
|
||||||
|
for (let i = 1; i <= totalPages; i++) items.push(i);
|
||||||
|
} else {
|
||||||
|
items.push(1);
|
||||||
|
const left = Math.max(2, page - 2);
|
||||||
|
const right = Math.min(totalPages - 1, page + 2);
|
||||||
|
if (left > 2) items.push('...');
|
||||||
|
for (let i = left; i <= right; i++) items.push(i);
|
||||||
|
if (right < totalPages - 1) items.push('...');
|
||||||
|
items.push(totalPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.map((p, idx) =>
|
||||||
|
typeof p === 'string' ? (
|
||||||
|
<Pagination.Ellipsis key={`ell-${idx}`} disabled />
|
||||||
|
) : (
|
||||||
|
<Pagination.Item
|
||||||
|
key={p}
|
||||||
|
active={p === page}
|
||||||
|
onClick={() => setPage(p as number)}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</Pagination.Item>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<Pagination.Next onClick={() => setPage(Math.min(totalPages, page + 1))} disabled={page === totalPages} />
|
||||||
|
<Pagination.Last onClick={() => setPage(totalPages)} disabled={page === totalPages} />
|
||||||
|
</Pagination>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
right={(
|
||||||
|
<Card className="data-table-card h-100 d-flex flex-column">
|
||||||
|
<Card.Header className="data-table-header">Node Details</Card.Header>
|
||||||
|
<Card.Body className="data-table-body d-flex flex-column">
|
||||||
|
{!selectedNode && (
|
||||||
|
<div className="text-secondary">Select a node to view details.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedNode && (
|
||||||
|
<div>
|
||||||
|
<h5>{renderHighlighted(selectedNode.name || '(unknown)', search)}</h5>
|
||||||
|
<div><strong>ID:</strong> <span className="meshcore-hash">{renderHighlighted(String(selectedNode.id || ''), search)}</span></div>
|
||||||
|
<div><strong>Prefix:</strong> <span className="meshcore-hash">{renderHighlighted(String(selectedNode.prefix || ''), search)}</span></div>
|
||||||
|
<div><strong>Type:</strong> {selectedNode.type}</div>
|
||||||
|
<div><strong>Public key:</strong> <span className="meshcore-hash">{selectedNode.public_key}</span></div>
|
||||||
|
<div><strong>First seen:</strong> <TimeAgo time={selectedNode.first_heard_at} /></div>
|
||||||
|
<div><strong>Last seen:</strong> <TimeAgo time={selectedNode.last_heard_at} /></div>
|
||||||
|
{selectedNode.last_latitude !== undefined && selectedNode.last_longitude !== undefined && (
|
||||||
|
<div><strong>Location:</strong> {selectedNode.last_latitude}, {selectedNode.last_longitude}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MeshCoreNodesView;
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React from 'react';
|
||||||
import { bytesToHex } from '@noble/hashes/utils.js';
|
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||||
|
|
||||||
import { Badge, Card, Stack } from 'react-bootstrap';
|
import { Card, Stack } from 'react-bootstrap';
|
||||||
|
|
||||||
import PacketDissectionViewer from '../../components/PacketDissectionViewer';
|
import PacketDissectionViewer from '../../components/PacketDissectionViewer';
|
||||||
import type { Segment } from '../../protocols/dissection.types';
|
import type { Segment } from '../../types/protocol/dissection.types';
|
||||||
|
|
||||||
import type { MeshCorePacketRecord } from './MeshCoreContext';
|
|
||||||
import {
|
import {
|
||||||
payloadNameByValue,
|
payloadNameByValue,
|
||||||
routeDisplayByValue,
|
routeDisplayByValue,
|
||||||
} from './MeshCoreData';
|
} from './MeshCoreData';
|
||||||
|
import { PayloadType, type AdvertPayload, type AnonReqPayload, type DecryptedGroupMessage, type GroupTextPayload, type Payload } from '../../types/protocol/meshcore.types';
|
||||||
|
import type { Packet } from '../../protocols/meshcore';
|
||||||
|
|
||||||
const HeaderFact: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
|
const HeaderFact: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
|
||||||
<div className="meshcore-fact-row">
|
<div className="meshcore-fact-row">
|
||||||
@@ -19,19 +20,9 @@ const HeaderFact: React.FC<{ label: string; value: React.ReactNode }> = ({ label
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const bitSlice = (value: number, msb: number, lsb: number): number => {
|
const buildMeshCoreSegments = (packet: Packet): Segment[] => {
|
||||||
const width = msb - lsb + 1;
|
const { pathHashSize, pathHashCount } = packet;
|
||||||
const mask = (1 << width) - 1;
|
const pathLength = pathHashSize * pathHashCount;
|
||||||
return (value >> lsb) & mask;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMeshCoreSegments = (packet: MeshCorePacketRecord): Segment[] => {
|
|
||||||
const pathField = packet.raw.length > 1 ? packet.raw[1] : 0;
|
|
||||||
const pathHashSize = bitSlice(pathField, 7, 6) + 1;
|
|
||||||
const pathHashCount = bitSlice(pathField, 5, 0);
|
|
||||||
const pathBytesExpected = pathHashCount === 0 || pathHashSize === 4 ? 0 : pathHashCount * pathHashSize;
|
|
||||||
const pathBytesAvailable = Math.min(pathBytesExpected, Math.max(packet.raw.length - 2, 0));
|
|
||||||
const payloadOffset = 2 + pathBytesAvailable;
|
|
||||||
|
|
||||||
const segments: Segment[] = [
|
const segments: Segment[] = [
|
||||||
{
|
{
|
||||||
@@ -75,14 +66,14 @@ const buildMeshCoreSegments = (packet: MeshCorePacketRecord): Segment[] => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (pathBytesAvailable > 0) {
|
if (pathLength > 0) {
|
||||||
segments.push({
|
segments.push({
|
||||||
name: 'Path Data',
|
name: 'Path Data',
|
||||||
offset: 2,
|
offset: 2,
|
||||||
byteCount: pathBytesAvailable,
|
byteCount: pathLength,
|
||||||
attributes: [
|
attributes: [
|
||||||
{
|
{
|
||||||
byteWidth: pathBytesAvailable,
|
byteWidth: pathLength,
|
||||||
type: 'bytes',
|
type: 'bytes',
|
||||||
name: `Path Hashes (${pathHashCount} × ${pathHashSize} bytes)`,
|
name: `Path Hashes (${pathHashCount} × ${pathHashSize} bytes)`,
|
||||||
},
|
},
|
||||||
@@ -90,22 +81,26 @@ const buildMeshCoreSegments = (packet: MeshCorePacketRecord): Segment[] => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const payloadLength = packet.raw.length - payloadOffset;
|
const payloadOffset = 2 + (packet.transportCodes ? packet.transportCodes.length * 2 : 0) + pathLength;
|
||||||
if (payloadLength > 0) {
|
if (packet.payload.length > 0) {
|
||||||
segments.push({
|
segments.push({
|
||||||
name: 'Payload',
|
name: 'Payload',
|
||||||
offset: payloadOffset,
|
offset: payloadOffset,
|
||||||
byteCount: payloadLength,
|
byteCount: packet.payload.length,
|
||||||
attributes: [
|
attributes: [
|
||||||
{
|
{
|
||||||
byteWidth: payloadLength,
|
byteWidth: packet.payload.length,
|
||||||
type: 'bytes',
|
type: 'bytes',
|
||||||
name: `Payload Data (${payloadLength} bytes)`,
|
name: `Payload Data (${packet.payload.length} bytes)`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (packet.segments) {
|
||||||
|
segments.push(...packet.segments);
|
||||||
|
}
|
||||||
|
|
||||||
return segments;
|
return segments;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -116,24 +111,24 @@ const asRecord = (value: unknown): Record<string, unknown> | null => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PayloadDetails: React.FC<{ packet: MeshCorePacketRecord }> = ({ packet }) => {
|
const PayloadDetails: React.FC<{ packet: Packet }> = ({ packet }) => {
|
||||||
const payload = packet.decodedPayload;
|
const payload = packet.decodedPayload as Payload | undefined;
|
||||||
const payloadObj = asRecord(payload);
|
const payloadObj = asRecord(payload);
|
||||||
|
|
||||||
if (!payloadObj) {
|
if (typeof payload === 'undefined' || !payload || !payloadObj) {
|
||||||
return (
|
return (
|
||||||
<Card body className="data-table-card">
|
<Card body className="data-table-card">
|
||||||
<h6 className="mb-2">Payload</h6>
|
<h6 className="mb-2">Payload</h6>
|
||||||
<div>Unable to decode payload; showing raw bytes only.</div>
|
<div>Unable to decode payload; showing raw bytes only.</div>
|
||||||
<code>{bytesToHex(packet.raw)}</code>
|
<code>{bytesToHex(packet.payload)}</code>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof payloadObj.flags === 'number' && payloadObj.data instanceof Uint8Array) {
|
if (typeof payloadObj?.flags === 'number' && payloadObj?.data instanceof Uint8Array) {
|
||||||
return (
|
return (
|
||||||
<Card body className="data-table-card">
|
<Card body className="data-table-card">
|
||||||
<h6 className="mb-2">CONTROL Payload</h6>
|
<h6 className="mb-2">Control</h6>
|
||||||
<HeaderFact label="Flags" value={`0x${payloadObj.flags.toString(16)}`} />
|
<HeaderFact label="Flags" value={`0x${payloadObj.flags.toString(16)}`} />
|
||||||
<HeaderFact label="Data Length" value={payloadObj.data.length} />
|
<HeaderFact label="Data Length" value={payloadObj.data.length} />
|
||||||
<HeaderFact label="Data" value={<code>{bytesToHex(payloadObj.data)}</code>} />
|
<HeaderFact label="Data" value={<code>{bytesToHex(payloadObj.data)}</code>} />
|
||||||
@@ -141,6 +136,18 @@ const PayloadDetails: React.FC<{ packet: MeshCorePacketRecord }> = ({ packet })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (packet.payloadType === PayloadType.GROUP_TEXT && typeof packet.decrypted !== 'undefined') {
|
||||||
|
const payload = packet.decodedPayload as GroupTextPayload;
|
||||||
|
const decrypted = packet.decrypted as DecryptedGroupMessage;
|
||||||
|
return (
|
||||||
|
<Card body className="data-table-card">
|
||||||
|
<h6 className="mb-2">{payloadNameByValue[packet.payloadType] ?? packet.payloadType} (Encrypted)</h6>
|
||||||
|
<HeaderFact label="Channel Hash" value={<><span className="meshcore-hash">{payload.channelHash}</span>{decrypted.group ? ` (${decrypted.group})` : ''}</>} />
|
||||||
|
<HeaderFact label="Timestamp" value={decrypted.timestamp.toLocaleString()} />
|
||||||
|
<HeaderFact label="Message" value={decrypted.message} />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
typeof payloadObj.channelHash === 'string'
|
typeof payloadObj.channelHash === 'string'
|
||||||
&& payloadObj.cipherText instanceof Uint8Array
|
&& payloadObj.cipherText instanceof Uint8Array
|
||||||
@@ -148,9 +155,9 @@ const PayloadDetails: React.FC<{ packet: MeshCorePacketRecord }> = ({ packet })
|
|||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<Card body className="data-table-card">
|
<Card body className="data-table-card">
|
||||||
<h6 className="mb-2">GROUP Payload</h6>
|
<h6 className="mb-2">{payloadNameByValue[packet.payloadType] ?? packet.payloadType} (Encrypted)</h6>
|
||||||
<HeaderFact label="Channel Hash" value={payloadObj.channelHash} />
|
<HeaderFact label="Channel Hash" value={payloadObj.channelHash} />
|
||||||
<HeaderFact label="Cipher Text Length" value={payloadObj.cipherText.length} />
|
<HeaderFact label="Cipher Text" value={payloadObj.cipherText.length + ' bytes'} />
|
||||||
<HeaderFact label="Cipher MAC" value={<code>{bytesToHex(payloadObj.cipherMAC)}</code>} />
|
<HeaderFact label="Cipher MAC" value={<code>{bytesToHex(payloadObj.cipherMAC)}</code>} />
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -164,9 +171,9 @@ const PayloadDetails: React.FC<{ packet: MeshCorePacketRecord }> = ({ packet })
|
|||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<Card body className="data-table-card">
|
<Card body className="data-table-card">
|
||||||
<h6 className="mb-2">Encrypted Payload</h6>
|
<h6 className="mb-2">{payloadNameByValue[packet.payloadType] ?? packet.payloadType} (Encrypted)</h6>
|
||||||
<HeaderFact label="Destination" value={payloadObj.dstHash} />
|
<HeaderFact label="Destination" value={<span className="meshcore-hash">{payloadObj.dstHash}</span>} />
|
||||||
<HeaderFact label="Source" value={payloadObj.srcHash} />
|
<HeaderFact label="Source" value={<span className="meshcore-hash">{payloadObj.srcHash}</span>} />
|
||||||
<HeaderFact label="Cipher Text Length" value={payloadObj.cipherText.length} />
|
<HeaderFact label="Cipher Text Length" value={payloadObj.cipherText.length} />
|
||||||
<HeaderFact label="Cipher MAC" value={<code>{bytesToHex(payloadObj.cipherMAC)}</code>} />
|
<HeaderFact label="Cipher MAC" value={<code>{bytesToHex(payloadObj.cipherMAC)}</code>} />
|
||||||
</Card>
|
</Card>
|
||||||
@@ -176,13 +183,47 @@ const PayloadDetails: React.FC<{ packet: MeshCorePacketRecord }> = ({ packet })
|
|||||||
if (payloadObj.data instanceof Uint8Array) {
|
if (payloadObj.data instanceof Uint8Array) {
|
||||||
return (
|
return (
|
||||||
<Card body className="data-table-card">
|
<Card body className="data-table-card">
|
||||||
<h6 className="mb-2">Raw Payload</h6>
|
<h6 className="mb-2">{payloadNameByValue[packet.payloadType] ?? packet.payloadType} (Raw)</h6>
|
||||||
<HeaderFact label="Data Length" value={payloadObj.data.length} />
|
<HeaderFact label="Data Length" value={payloadObj.data.length} />
|
||||||
<HeaderFact label="Data" value={<code>{bytesToHex(payloadObj.data)}</code>} />
|
<HeaderFact label="Data" value={<code>{bytesToHex(payloadObj.data)}</code>} />
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (packet.payloadType === PayloadType.ADVERT) {
|
||||||
|
const advert = payload as AdvertPayload;
|
||||||
|
return (
|
||||||
|
<Card body className="data-table-card">
|
||||||
|
<h6 className="mb-2">Advert Payload</h6>
|
||||||
|
<HeaderFact label="Public Key" value={<code>{bytesToHex(advert.publicKey)}</code>} />
|
||||||
|
<HeaderFact label="Signature" value={advert.signature.length + ' Bytes'} />
|
||||||
|
{advert.timestamp && (
|
||||||
|
<HeaderFact label="Time" value={<code>{advert.timestamp.toISOString()}</code>} />
|
||||||
|
)}
|
||||||
|
{advert.appdata?.name && (
|
||||||
|
<HeaderFact label="Name" value={advert.appdata.name} />
|
||||||
|
)}
|
||||||
|
{advert.appdata?.latitude && (
|
||||||
|
<HeaderFact label="Latitude" value={advert.appdata.latitude} />
|
||||||
|
)}
|
||||||
|
{advert.appdata?.longitude && (
|
||||||
|
<HeaderFact label="Longitude" value={advert.appdata.longitude} />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packet.payloadType === PayloadType.ANON_REQ) {
|
||||||
|
const anonReq = payload as AnonReqPayload;
|
||||||
|
return (
|
||||||
|
<Card body className="data-table-card">
|
||||||
|
<h6 className="mb-2">Anonymous Request</h6>
|
||||||
|
<HeaderFact label="Destination" value={anonReq.dstHash} />
|
||||||
|
<HeaderFact label="Public Key" value={<code>{bytesToHex(anonReq.publicKey)}</code>} />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card body className="data-table-card">
|
<Card body className="data-table-card">
|
||||||
<h6 className="mb-2">Payload</h6>
|
<h6 className="mb-2">Payload</h6>
|
||||||
@@ -192,7 +233,7 @@ const PayloadDetails: React.FC<{ packet: MeshCorePacketRecord }> = ({ packet })
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface MeshCorePacketDetailsPaneProps {
|
interface MeshCorePacketDetailsPaneProps {
|
||||||
packet: MeshCorePacketRecord | null;
|
packet: Packet | null;
|
||||||
streamReady: boolean;
|
streamReady: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +250,7 @@ const MeshCorePacketDetailsPane: React.FC<MeshCorePacketDetailsPaneProps> = ({ p
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap={2} className="h-100 meshcore-detail-stack">
|
<Stack gap={2} className="h-100 meshcore-detail-stack">
|
||||||
|
{/*
|
||||||
<Card body className="data-table-card">
|
<Card body className="data-table-card">
|
||||||
<Stack direction="horizontal" gap={2} className="mb-2">
|
<Stack direction="horizontal" gap={2} className="mb-2">
|
||||||
<h6 className="mb-0">{payloadNameByValue[packet.payloadType] ?? packet.payloadType}</h6>
|
<h6 className="mb-0">{payloadNameByValue[packet.payloadType] ?? packet.payloadType}</h6>
|
||||||
@@ -225,14 +267,15 @@ const MeshCorePacketDetailsPane: React.FC<MeshCorePacketDetailsPaneProps> = ({ p
|
|||||||
<HeaderFact label="Path" value={<code>{bytesToHex(packet.path)}</code>} />
|
<HeaderFact label="Path" value={<code>{bytesToHex(packet.path)}</code>} />
|
||||||
<HeaderFact label="Raw Packet" value={<code>{bytesToHex(packet.raw)}</code>} />
|
<HeaderFact label="Raw Packet" value={<code>{bytesToHex(packet.raw)}</code>} />
|
||||||
</Card>
|
</Card>
|
||||||
|
*/}
|
||||||
|
<PayloadDetails packet={packet} />
|
||||||
<Card body className="data-table-card">
|
<Card body className="data-table-card">
|
||||||
<PacketDissectionViewer
|
<PacketDissectionViewer
|
||||||
rawPacket={packet.raw}
|
rawPacket={packet.toBytes()}
|
||||||
segments={buildMeshCoreSegments(packet)}
|
segments={packet.segments || buildMeshCoreSegments(packet)}
|
||||||
title="Packet Bytes (Wire View)"
|
title={`${payloadNameByValue[packet.payloadType] ?? packet.payloadType} Packet Dissection`}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
<PayloadDetails packet={packet} />
|
|
||||||
<Card body className="data-table-card">
|
<Card body className="data-table-card">
|
||||||
<h6 className="mb-2">Stream Preparation</h6>
|
<h6 className="mb-2">Stream Preparation</h6>
|
||||||
<div>MeshCore stream service is initialized and ready for topic subscriptions.</div>
|
<div>MeshCore stream service is initialized and ready for topic subscriptions.</div>
|
||||||
|
|||||||
@@ -1,11 +1,37 @@
|
|||||||
import React from 'react';
|
import React, { type JSX } from 'react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
import { bytesToHex } from '@noble/hashes/utils.js';
|
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||||
|
|
||||||
|
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
|
import GpsFixedIcon from '@mui/icons-material/GpsFixed';
|
||||||
|
import GpsOffIcon from '@mui/icons-material/GpsOff';
|
||||||
|
import LockIcon from '@mui/icons-material/Lock';
|
||||||
|
|
||||||
import { PayloadType } from '../../types/protocol/meshcore.types';
|
import { Packet } from '../../protocols/meshcore';
|
||||||
import type { MeshCorePacketRecord } from './MeshCoreContext';
|
import {
|
||||||
|
PayloadType,
|
||||||
|
type DecryptedGroupMessage,
|
||||||
|
type GroupTextPayload,
|
||||||
|
type TextPayload,
|
||||||
|
type RequestPayload,
|
||||||
|
type ResponsePayload,
|
||||||
|
type AdvertPayload,
|
||||||
|
type PathPayload,
|
||||||
|
type TracePayload,
|
||||||
|
type MultipartPayload,
|
||||||
|
type ControlPayload,
|
||||||
|
type AckPayload,
|
||||||
|
type GroupDataPayload,
|
||||||
|
type AnonReqPayload,
|
||||||
|
type RawCustomPayload,
|
||||||
|
type DecryptedTextMessage,
|
||||||
|
type DecryptedRequest,
|
||||||
|
type DecryptedResponse,
|
||||||
|
type DecryptedPath,
|
||||||
|
} from '../../types/protocol/meshcore.types';
|
||||||
|
import SignalGrade from '../../components/SignalGrade';
|
||||||
import {
|
import {
|
||||||
payloadNameByValue,
|
payloadNameByValue,
|
||||||
PayloadTypeIcon,
|
PayloadTypeIcon,
|
||||||
@@ -29,22 +55,17 @@ const getPayloadTypeColor = (payloadType: number): string => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPathInfo = (packet: MeshCorePacketRecord): { prefixes: string; hopCount: number } => {
|
const getPathInfo = (packet: Packet): { prefixes: string; hopCount: number } => {
|
||||||
if (packet.raw.length < 2) {
|
const hashCount = packet.getPathHashCount();
|
||||||
return { prefixes: 'none', hopCount: 0 };
|
const hashSize = packet.getPathHashSize();
|
||||||
}
|
|
||||||
|
|
||||||
const pathField = packet.raw[1];
|
if (!packet.path || hashCount === 0 || hashSize === 4) {
|
||||||
const hashSize = (pathField >> 6) + 1;
|
|
||||||
const hashCount = pathField & 0x3f;
|
|
||||||
|
|
||||||
if (hashCount === 0 || hashSize === 4) {
|
|
||||||
return { prefixes: 'none', hopCount: 0 };
|
return { prefixes: 'none', hopCount: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathByteLength = hashCount * hashSize;
|
const pathByteLength = hashCount * hashSize;
|
||||||
const availablePathBytes = Math.min(pathByteLength, Math.max(packet.raw.length - 2, 0));
|
const availablePathBytes = Math.min(pathByteLength, Math.max(packet.path.length, 0));
|
||||||
const pathBytes = packet.raw.slice(2, 2 + availablePathBytes);
|
const pathBytes = packet.path.slice(0, availablePathBytes);
|
||||||
|
|
||||||
if (pathBytes.length === 0) {
|
if (pathBytes.length === 0) {
|
||||||
return { prefixes: 'none', hopCount: 0 };
|
return { prefixes: 'none', hopCount: 0 };
|
||||||
@@ -67,15 +88,15 @@ const getPathInfo = (packet: MeshCorePacketRecord): { prefixes: string; hopCount
|
|||||||
|
|
||||||
export interface MeshCorePacketGroup {
|
export interface MeshCorePacketGroup {
|
||||||
hash: string;
|
hash: string;
|
||||||
packets: MeshCorePacketRecord[];
|
packets: Packet[];
|
||||||
mostRecent: MeshCorePacketRecord;
|
mostRecent: Packet;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MeshCorePacketRowsProps {
|
interface MeshCorePacketRowsProps {
|
||||||
groupedPackets: MeshCorePacketGroup[];
|
groupedPackets: MeshCorePacketGroup[];
|
||||||
expandedHashes: Set<string>;
|
expandedHashes: Set<string>;
|
||||||
onToggleExpanded: (hash: string) => void;
|
onToggleExpanded: (hash: string) => void;
|
||||||
onSelect: (packet: MeshCorePacketRecord) => void;
|
onSelect: (packet: Packet) => void;
|
||||||
selectedHash: string | null;
|
selectedHash: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +107,234 @@ const MeshCorePacketRows: React.FC<MeshCorePacketRowsProps> = ({
|
|||||||
onSelect,
|
onSelect,
|
||||||
selectedHash,
|
selectedHash,
|
||||||
}) => {
|
}) => {
|
||||||
|
const formatSrcDst = (srcHash?: string, dstHash?: string, reverse: boolean = false): JSX.Element => {
|
||||||
|
const src = srcHash ? srcHash.slice(0, 8) : '??';
|
||||||
|
const dst = dstHash ? dstHash.slice(0, 8) : '??';
|
||||||
|
return (
|
||||||
|
<span className="meshcore-path">
|
||||||
|
<span className="meshcore-hash">{src}</span> {reverse ? '←' : '→'} <span className="meshcore-hash">{dst}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PayloadTextSummary: React.FC<{ packet: Packet }> = ({ packet }) => {
|
||||||
|
// If the packet contains a decrypted group message, prefer that
|
||||||
|
if (packet.decrypted && packet.payloadType === PayloadType.GROUP_TEXT) {
|
||||||
|
const dg = packet.decrypted as DecryptedGroupMessage;
|
||||||
|
let nick: string | null = null;
|
||||||
|
let message: string | null = null;
|
||||||
|
|
||||||
|
if (typeof dg.message === 'string') {
|
||||||
|
const parts = dg.message.split(':');
|
||||||
|
if (parts.length > 1) {
|
||||||
|
nick = parts.shift()!.trim();
|
||||||
|
message = parts.join(':').trim();
|
||||||
|
} else {
|
||||||
|
message = dg.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nick && message) {
|
||||||
|
return (
|
||||||
|
<div className="meshcore-payload-text">
|
||||||
|
{dg.group && <span className="meshcore-payload-group"><em>{dg.group}</em> </span>}
|
||||||
|
<strong className="meshcore-payload-nick">{nick}</strong>
|
||||||
|
{': '}
|
||||||
|
<span className="meshcore-payload-message">{message}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message) {
|
||||||
|
return <div className="meshcore-payload-text">{message}</div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: show channel hash when payload decoded but not decrypted, or raw hex
|
||||||
|
const payload = packet.decodedPayload as GroupTextPayload;
|
||||||
|
return (
|
||||||
|
<div className="meshcore-payload-text">
|
||||||
|
<em><LockIcon fontSize="small" /> Group message:</em>
|
||||||
|
<span className="meshcore-hash">#{payload.channelHash}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TextPayloadSummary: React.FC<{ packet: Packet }> = ({ packet }) => {
|
||||||
|
const payload = packet.decodedPayload as TextPayload;
|
||||||
|
const dec = packet.decrypted as DecryptedTextMessage | undefined;
|
||||||
|
if (dec && typeof dec.message === 'string') return <div className="meshcore-payload-text">{dec.message}</div>;
|
||||||
|
return (
|
||||||
|
<div className="meshcore-payload-text">
|
||||||
|
<em><LockIcon fontSize="small" /> Text message:</em>
|
||||||
|
{formatSrcDst(payload.srcHash, payload.dstHash)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RequestPayloadSummary: React.FC<{ packet: Packet }> = ({ packet }) => {
|
||||||
|
const payload = packet.decodedPayload as RequestPayload;
|
||||||
|
const dec = packet.decrypted as DecryptedRequest | undefined;
|
||||||
|
if (dec) {
|
||||||
|
return (
|
||||||
|
<div className="meshcore-payload-text">
|
||||||
|
<div>{`Request type: ${dec.requestType}`}</div>
|
||||||
|
<div>{`Timestamp: ${dec.timestamp.toLocaleString()}`}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="meshcore-payload-text">
|
||||||
|
<em><LockIcon fontSize="small" /> Request:</em>
|
||||||
|
{formatSrcDst(payload.srcHash, payload.dstHash)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ResponsePayloadSummary: React.FC<{ packet: Packet }> = ({ packet }) => {
|
||||||
|
const payload = packet.decodedPayload as ResponsePayload;
|
||||||
|
const dec = packet.decrypted as DecryptedResponse | undefined;
|
||||||
|
if (dec) {
|
||||||
|
return <div className="meshcore-payload-text">{`Response tag: ${dec.tag}`}</div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="meshcore-payload-text">
|
||||||
|
<em><LockIcon fontSize="small" /> Response:</em>
|
||||||
|
{formatSrcDst(payload.srcHash, payload.dstHash, true)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AdvertPayloadSummary: React.FC<{ packet: Packet }> = ({ packet }) => {
|
||||||
|
const payload = packet.decodedPayload as AdvertPayload;
|
||||||
|
return (
|
||||||
|
<div className="meshcore-payload-text">
|
||||||
|
<Link to={`/meshcore/nodes/${bytesToHex(payload.publicKey)}`}>{payload.appdata?.name ? payload.appdata.name : bytesToHex(payload.publicKey)}</Link>
|
||||||
|
{(payload.appdata?.latitude !== undefined && payload.appdata?.longitude !== undefined) ? (
|
||||||
|
<span className="meshcore-payload-meta">
|
||||||
|
<GpsFixedIcon fontSize="small" />
|
||||||
|
{`${payload.appdata.latitude.toFixed(6)}, ${payload.appdata.longitude.toFixed(6)}`}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="meshcore-payload-meta">
|
||||||
|
<GpsOffIcon fontSize="small" />
|
||||||
|
No location
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PathPayloadSummary: React.FC<{ packet: Packet }> = ({ packet }) => {
|
||||||
|
const payload = packet.decodedPayload as PathPayload;
|
||||||
|
const dec = packet.decrypted as DecryptedPath | undefined;
|
||||||
|
if (dec) {
|
||||||
|
return (
|
||||||
|
<div className="meshcore-payload-text">
|
||||||
|
<div>{`Path (${dec.path.length} hops)`}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <div className="meshcore-payload-text"><em><LockIcon fontSize="small" /> Path</em></div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TracePayloadSummary: React.FC<{ packet: Packet }> = ({ packet }) => {
|
||||||
|
const payload = packet.decodedPayload as TracePayload;
|
||||||
|
const nodesBuf = payload.nodes ?? new Uint8Array();
|
||||||
|
const nodes: { id: string; snr: number }[] = [];
|
||||||
|
for (let offset = 0; offset + 1 < nodesBuf.length; offset += 2) {
|
||||||
|
const nodeId = nodesBuf[offset].toString(16).padStart(2, '0');
|
||||||
|
const snr = new Int8Array(nodesBuf.buffer, nodesBuf.byteOffset + offset + 1, 1)[0];
|
||||||
|
nodes.push({ id: nodeId, snr: snr / 4.0}); // SNR is encoded as 1/4 dB steps
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="meshcore-payload-text">
|
||||||
|
{nodes.map((n, i) => (
|
||||||
|
<span key={`${packet.hash()}-${i}`}>
|
||||||
|
<span className="meshcore-trace-node">
|
||||||
|
{`${n.id !== '00' ? n.id : 'origin'} (${n.snr}dB)`}
|
||||||
|
</span>
|
||||||
|
{i < nodes.length - 1 && <ArrowForwardIcon style={{ fontSize: '0.75rem', margin: '0 4px' }} />}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MultipartPayloadSummary: React.FC<{ packet: Packet }> = ({ packet }) => {
|
||||||
|
const payload = packet.decodedPayload as MultipartPayload;
|
||||||
|
return <div className="meshcore-payload-text">{`Multipart (${payload.data?.length ?? 0} bytes)`}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ControlPayloadSummary: React.FC<{ packet: Packet }> = ({ packet }) => {
|
||||||
|
const payload = packet.decodedPayload as ControlPayload;
|
||||||
|
return <div className="meshcore-payload-text">{`Control flags: 0x${payload.flags.toString(16)}`}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AckPayloadSummary: React.FC<{ packet: Packet }> = ({ packet }) => {
|
||||||
|
const payload = packet.decodedPayload as AckPayload;
|
||||||
|
return (
|
||||||
|
<div className="meshcore-payload-text">
|
||||||
|
Acknowledgement: <code>{bytesToHex(payload.checksum)}</code>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const GroupDataPayloadSummary: React.FC<{ packet: Packet }> = ({ packet }) => {
|
||||||
|
const payload = packet.decodedPayload as GroupDataPayload;
|
||||||
|
return <div className="meshcore-payload-text"><em>Encrypted group datagram</em> <span className="meshcore-payload-meta">{`channel: #${payload.channelHash}`}</span></div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AnonReqPayloadSummary: React.FC<{ packet: Packet }> = ({ packet }) => {
|
||||||
|
const payload = packet.decodedPayload as AnonReqPayload;
|
||||||
|
return (
|
||||||
|
<div className="meshcore-payload-text">
|
||||||
|
<em><LockIcon fontSize="small" /> Anonymous request to:</em>
|
||||||
|
<span className="meshcore-hash">{payload.dstHash}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RawCustomPayloadSummary: React.FC<{ packet: Packet }> = ({ packet }) => {
|
||||||
|
const payload = packet.decodedPayload as RawCustomPayload;
|
||||||
|
return <div className="meshcore-payload-text">{`Raw custom payload (${payload.data?.length ?? 0} bytes)`}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPayloadSummary = (packet: Packet): React.ReactNode => {
|
||||||
|
if (typeof packet.decodedPayload !== 'undefined') {
|
||||||
|
switch (packet.payloadType) {
|
||||||
|
case PayloadType.GROUP_TEXT:
|
||||||
|
return <PayloadTextSummary packet={packet} />;
|
||||||
|
case PayloadType.TEXT:
|
||||||
|
return <TextPayloadSummary packet={packet} />;
|
||||||
|
case PayloadType.REQUEST:
|
||||||
|
return <RequestPayloadSummary packet={packet} />;
|
||||||
|
case PayloadType.RESPONSE:
|
||||||
|
return <ResponsePayloadSummary packet={packet} />;
|
||||||
|
case PayloadType.ADVERT:
|
||||||
|
return <AdvertPayloadSummary packet={packet} />;
|
||||||
|
case PayloadType.PATH:
|
||||||
|
return <PathPayloadSummary packet={packet} />;
|
||||||
|
case PayloadType.TRACE:
|
||||||
|
return <TracePayloadSummary packet={packet} />;
|
||||||
|
case PayloadType.MULTIPART:
|
||||||
|
return <MultipartPayloadSummary packet={packet} />;
|
||||||
|
case PayloadType.CONTROL:
|
||||||
|
return <ControlPayloadSummary packet={packet} />;
|
||||||
|
case PayloadType.ACK:
|
||||||
|
return <AckPayloadSummary packet={packet} />;
|
||||||
|
case PayloadType.GROUP_DATA:
|
||||||
|
return <GroupDataPayloadSummary packet={packet} />;
|
||||||
|
case PayloadType.ANON_REQ:
|
||||||
|
return <AnonReqPayloadSummary packet={packet} />;
|
||||||
|
case PayloadType.RAW_CUSTOM:
|
||||||
|
return <RawCustomPayloadSummary packet={packet} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hex = bytesToHex(packet.payload ?? new Uint8Array());
|
||||||
|
return <span className="meshcore-payload-hex">{hex.length > 48 ? `${hex.slice(0, 48)}…` : hex}</span>;
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{groupedPackets.map((group) => {
|
{groupedPackets.map((group) => {
|
||||||
@@ -98,14 +347,14 @@ const MeshCorePacketRows: React.FC<MeshCorePacketRowsProps> = ({
|
|||||||
if (pathA !== pathB) {
|
if (pathA !== pathB) {
|
||||||
return pathA - pathB;
|
return pathA - pathB;
|
||||||
}
|
}
|
||||||
return b.timestamp.getTime() - a.timestamp.getTime();
|
return b.receivedAt.getTime() - a.receivedAt.getTime();
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={group.hash}>
|
<React.Fragment key={group.hash}>
|
||||||
<tr
|
<tr
|
||||||
data-nav-item="true"
|
data-nav-item="true"
|
||||||
className={`${getPayloadTypeColor(packet.payloadType)} ${selectedHash === packet.hash ? 'is-selected' : ''}`}
|
className={`${getPayloadTypeColor(packet.payloadType)} ${selectedHash === packet.hash() ? 'is-selected' : ''}`}
|
||||||
onClick={() => onSelect(packet)}
|
onClick={() => onSelect(packet)}
|
||||||
>
|
>
|
||||||
<td>
|
<td>
|
||||||
@@ -123,42 +372,49 @@ const MeshCorePacketRows: React.FC<MeshCorePacketRowsProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td className="font-monospace">
|
||||||
{packet.timestamp.toLocaleTimeString()}
|
{packet.receivedAt.toLocaleTimeString()}
|
||||||
{hasDuplicates && (
|
{hasDuplicates && (
|
||||||
<span className="meshcore-duplicate-badge" title={`${group.packets.length} instances`}>
|
<span className="meshcore-duplicate-badge" title={`${group.packets.length} instances`}>
|
||||||
{' '}×{group.packets.length}
|
{' '}×{group.packets.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>{packet.snr !== undefined ? packet.snr.toFixed(1) : '-'} dB</td>
|
<td className="text-end">
|
||||||
|
{packet.snr !== undefined && packet.snr !== null ? packet.snr.toFixed(0) + 'dB' : '-'}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button type="button" className="meshcore-hash-button" onClick={() => onSelect(packet)}>
|
<SignalGrade snr={packet.snr} rssi={(packet as any).rssi} />
|
||||||
{packet.hash}
|
</td>
|
||||||
</button>
|
<td>
|
||||||
|
<div className="meshcore-hash">
|
||||||
|
{packet.hash()}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="meshcore-payload-type-cell" title={payloadNameByValue[packet.payloadType] ?? `0x${packet.payloadType.toString(16)}`}>
|
<td className="meshcore-payload-type-cell" title={payloadNameByValue[packet.payloadType] ?? `0x${packet.payloadType.toString(16)}`}>
|
||||||
<PayloadTypeIcon payloadType={packet.payloadType} />
|
<PayloadTypeIcon payloadType={packet.payloadType} />
|
||||||
</td>
|
</td>
|
||||||
<td>{packet.payloadSummary}</td>
|
<td>{renderPayloadSummary(packet)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{isExpanded && expandedPackets.map((duplicatePacket, index) => (
|
{isExpanded && expandedPackets.map((duplicatePacket, index) => (
|
||||||
<tr
|
<tr
|
||||||
key={`${group.hash}-${index}`}
|
key={`${group.hash}-${index}`}
|
||||||
className={`${getPayloadTypeColor(duplicatePacket.payloadType)} ${selectedHash === duplicatePacket.hash ? 'is-selected' : ''}`}
|
className={`${getPayloadTypeColor(duplicatePacket.payloadType)} ${selectedHash === duplicatePacket.hash() ? 'is-selected' : ''}`}
|
||||||
onClick={() => onSelect(duplicatePacket)}
|
onClick={() => onSelect(duplicatePacket)}
|
||||||
>
|
>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td>{duplicatePacket.timestamp.toLocaleTimeString()}</td>
|
<td>{duplicatePacket.receivedAt.toLocaleTimeString()}</td>
|
||||||
<td>
|
<td>
|
||||||
<button type="button" className="meshcore-hash-button" onClick={() => onSelect(duplicatePacket)}>
|
<button type="button" className="meshcore-hash-button" onClick={() => onSelect(duplicatePacket)}>
|
||||||
{duplicatePacket.hash}
|
{duplicatePacket.hash()}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="meshcore-payload-type-cell" title={payloadNameByValue[duplicatePacket.payloadType] ?? `0x${duplicatePacket.payloadType.toString(16)}`}>
|
<td className="meshcore-payload-type-cell" title={payloadNameByValue[duplicatePacket.payloadType] ?? `0x${duplicatePacket.payloadType.toString(16)}`}>
|
||||||
<PayloadTypeIcon payloadType={duplicatePacket.payloadType} />
|
<PayloadTypeIcon payloadType={duplicatePacket.payloadType} />
|
||||||
</td>
|
</td>
|
||||||
<td>{duplicatePacket.snr !== undefined ? duplicatePacket.snr.toFixed(1) : '-'} dB</td>
|
<td>
|
||||||
|
<SignalGrade snr={duplicatePacket.snr} rssi={(duplicatePacket as any).rssi} />
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{getPathInfo(duplicatePacket).prefixes}
|
{getPathInfo(duplicatePacket).prefixes}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import StreamStatus from '../../components/StreamStatus';
|
|||||||
import KeyboardShortcutsModal from '../../components/KeyboardShortcutsModal';
|
import KeyboardShortcutsModal from '../../components/KeyboardShortcutsModal';
|
||||||
import { useKeyboardListNavigation } from '../../hooks/useKeyboardListNavigation';
|
import { useKeyboardListNavigation } from '../../hooks/useKeyboardListNavigation';
|
||||||
import { useRadiosByProtocol } from '../../contexts/RadiosContext';
|
import { useRadiosByProtocol } from '../../contexts/RadiosContext';
|
||||||
import type { MeshCorePacketRecord } from './MeshCoreContext';
|
|
||||||
import {
|
import {
|
||||||
payloadUrlByValue,
|
payloadUrlByValue,
|
||||||
payloadValueByUrl,
|
payloadValueByUrl,
|
||||||
@@ -18,11 +17,12 @@ import {
|
|||||||
} from './MeshCoreData';
|
} from './MeshCoreData';
|
||||||
import MeshCorePacketFilters from './MeshCorePacketFilters';
|
import MeshCorePacketFilters from './MeshCorePacketFilters';
|
||||||
import MeshCorePacketRows, { type MeshCorePacketGroup } from './MeshCorePacketRows';
|
import MeshCorePacketRows, { type MeshCorePacketGroup } from './MeshCorePacketRows';
|
||||||
|
import type { Packet } from '../../protocols/meshcore';
|
||||||
|
|
||||||
interface MeshCorePacketTableProps {
|
interface MeshCorePacketTableProps {
|
||||||
packets: MeshCorePacketRecord[];
|
packets: Packet[];
|
||||||
selectedHash: string | null;
|
selectedHash: string | null;
|
||||||
onSelect: (packet: MeshCorePacketRecord) => void;
|
onSelect: (packet: Packet) => void;
|
||||||
onClearSelection: () => void;
|
onClearSelection: () => void;
|
||||||
streamReady: boolean;
|
streamReady: boolean;
|
||||||
}
|
}
|
||||||
@@ -94,30 +94,34 @@ const MeshCorePacketTable: React.FC<MeshCorePacketTableProps> = ({ packets, sele
|
|||||||
}, [packets, radios]);
|
}, [packets, radios]);
|
||||||
|
|
||||||
const groupedPackets = useMemo((): MeshCorePacketGroup[] => {
|
const groupedPackets = useMemo((): MeshCorePacketGroup[] => {
|
||||||
const groups = new Map<string, MeshCorePacketRecord[]>();
|
const groups = new Map<string, Packet[]>();
|
||||||
|
|
||||||
packets.forEach((packet) => {
|
for (const packet of packets) {
|
||||||
if (filterPayloadTypes.size > 0 && !filterPayloadTypes.has(packet.payloadType)) return;
|
if (filterPayloadTypes.size > 0 && !filterPayloadTypes.has(packet.payloadType)) continue;
|
||||||
if (filterRouteTypes.size > 0 && !filterRouteTypes.has(packet.routeType)) return;
|
if (filterRouteTypes.size > 0 && !filterRouteTypes.has(packet.routeType)) continue;
|
||||||
if (filterRadios.size > 0 && packet.radioName && !filterRadios.has(packet.radioName)) return;
|
if (filterRadios.size > 0 && packet.radioName && !filterRadios.has(packet.radioName)) continue;
|
||||||
|
|
||||||
const existing = groups.get(packet.hash);
|
const hash = packet.hash();
|
||||||
|
const existing = groups.get(hash);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.push(packet);
|
existing.push(packet);
|
||||||
} else {
|
} else {
|
||||||
groups.set(packet.hash, [packet]);
|
groups.set(hash, [packet]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
return Array.from(groups.entries())
|
return Array.from(groups.entries())
|
||||||
.map(([hash, grouped]) => ({
|
.map(([hash, grouped]) => ({
|
||||||
hash,
|
hash,
|
||||||
packets: grouped.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()),
|
packets: grouped.sort((a, b) => b.receivedAt.getTime() - a.receivedAt.getTime()),
|
||||||
|
leastRecent: grouped.reduce((oldest, packet) => (
|
||||||
|
packet.receivedAt < oldest.receivedAt ? packet : oldest
|
||||||
|
)),
|
||||||
mostRecent: grouped.reduce((latest, packet) => (
|
mostRecent: grouped.reduce((latest, packet) => (
|
||||||
packet.timestamp > latest.timestamp ? packet : latest
|
packet.receivedAt > latest.receivedAt ? packet : latest
|
||||||
)),
|
)),
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => b.mostRecent.timestamp.getTime() - a.mostRecent.timestamp.getTime());
|
.sort((a, b) => b.leastRecent.receivedAt.getTime() - a.leastRecent.receivedAt.getTime());
|
||||||
}, [packets, filterPayloadTypes, filterRouteTypes, filterRadios]);
|
}, [packets, filterPayloadTypes, filterRouteTypes, filterRadios]);
|
||||||
|
|
||||||
const handlePayloadTypeToggle = (value: number, isChecked: boolean) => {
|
const handlePayloadTypeToggle = (value: number, isChecked: boolean) => {
|
||||||
@@ -165,7 +169,7 @@ const MeshCorePacketTable: React.FC<MeshCorePacketTableProps> = ({ packets, sele
|
|||||||
if (!selectedHash) {
|
if (!selectedHash) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const index = navigablePackets.findIndex((packet) => packet.hash === selectedHash);
|
const index = navigablePackets.findIndex((packet) => packet.hash() === selectedHash);
|
||||||
return index >= 0 ? index : null;
|
return index >= 0 ? index : null;
|
||||||
}, [navigablePackets, selectedHash]);
|
}, [navigablePackets, selectedHash]);
|
||||||
|
|
||||||
@@ -212,11 +216,12 @@ const MeshCorePacketTable: React.FC<MeshCorePacketTableProps> = ({ packets, sele
|
|||||||
<Table hover responsive className="data-table mb-0" size="sm">
|
<Table hover responsive className="data-table mb-0" size="sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={{ width: '30px' }}></th>
|
<th style={{ width: '2ch' }}></th>
|
||||||
<th style={{ width: '100px' }}>Time</th>
|
<th style={{ width: '7ch' }}>Time</th>
|
||||||
<th style={{ width: '60px' }}>SNR</th>
|
<th style={{ width: '4ch' }}>SNR</th>
|
||||||
<th style={{ width: '80px' }}>Hash</th>
|
<th style={{ width: '9ch' }}>Quality</th>
|
||||||
<th style={{ width: '50px' }}>Type</th>
|
<th style={{ width: '12ch' }}>Hash</th>
|
||||||
|
<th style={{ width: '6ch' }}>Type</th>
|
||||||
<th>Info</th>
|
<th>Info</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const MeshCorePacketsView: React.FC = () => {
|
|||||||
if (!selectedHash) {
|
if (!selectedHash) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return packets.find((packet) => packet.hash === selectedHash) ?? null;
|
return packets.find((packet) => packet.hash() === selectedHash) ?? null;
|
||||||
}, [packets, selectedHash]);
|
}, [packets, selectedHash]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -23,7 +23,7 @@ const MeshCorePacketsView: React.FC = () => {
|
|||||||
<MeshCorePacketTable
|
<MeshCorePacketTable
|
||||||
packets={packets}
|
packets={packets}
|
||||||
selectedHash={selectedHash}
|
selectedHash={selectedHash}
|
||||||
onSelect={(packet) => setSelectedHash(packet.hash)}
|
onSelect={(packet) => setSelectedHash(packet.hash())}
|
||||||
onClearSelection={() => setSelectedHash(null)}
|
onClearSelection={() => setSelectedHash(null)}
|
||||||
streamReady={streamReady}
|
streamReady={streamReady}
|
||||||
/>
|
/>
|
||||||
|
|||||||
596
ui/src/pages/meshcore/MeshCoreStatsView.tsx
Normal file
596
ui/src/pages/meshcore/MeshCoreStatsView.tsx
Normal file
@@ -0,0 +1,596 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Alert, ButtonGroup, Card, Form, ListGroup, Spinner } from 'react-bootstrap';
|
||||||
|
import { ChartContainer } from '@mui/x-charts/ChartContainer';
|
||||||
|
import { ChartsTooltip } from '@mui/x-charts/ChartsTooltip';
|
||||||
|
import { ChartsXAxis } from '@mui/x-charts/ChartsXAxis';
|
||||||
|
import { ChartsYAxis } from '@mui/x-charts/ChartsYAxis';
|
||||||
|
import { BarChart, BarPlot } from '@mui/x-charts/BarChart';
|
||||||
|
import { LineChart, LinePlot, MarkPlot } from '@mui/x-charts/LineChart';
|
||||||
|
|
||||||
|
import API from '../../services/API';
|
||||||
|
import MeshCoreServiceImpl, {
|
||||||
|
type MeshCoreOriginStats,
|
||||||
|
type MeshCorePacketStats,
|
||||||
|
} from '../../services/MeshCoreService';
|
||||||
|
import { PayloadType } from '../../types/protocol/meshcore.types';
|
||||||
|
import VerticalSplit from '../../components/VerticalSplit';
|
||||||
|
|
||||||
|
const meshCoreService = new MeshCoreServiceImpl(API);
|
||||||
|
|
||||||
|
type MeshCoreStatsView = 'daily' | 'weekly' | 'monthly';
|
||||||
|
|
||||||
|
const viewLabelByKey: Record<MeshCoreStatsView, string> = {
|
||||||
|
daily: 'Daily',
|
||||||
|
weekly: 'Weekly',
|
||||||
|
monthly: 'Monthly',
|
||||||
|
};
|
||||||
|
|
||||||
|
const descriptionByView: Record<MeshCoreStatsView, string> = {
|
||||||
|
daily: 'Last 24 hours in 30-minute buckets.',
|
||||||
|
weekly: 'Last 7 days in 4-hour buckets.',
|
||||||
|
monthly: 'Last 30 days in 1-day buckets.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const originPalette = [
|
||||||
|
'#FF4D4F',
|
||||||
|
'#40A9FF',
|
||||||
|
'#73D13D',
|
||||||
|
'#9254DE',
|
||||||
|
'#13C2C2',
|
||||||
|
'#EB2F96',
|
||||||
|
'#A0D911',
|
||||||
|
'#FA8C16',
|
||||||
|
'#2F54EB',
|
||||||
|
'#36CFC9',
|
||||||
|
'#F759AB',
|
||||||
|
'#FFC53D',
|
||||||
|
'#B37FEB',
|
||||||
|
'#5CDBD3',
|
||||||
|
'#FF7A45',
|
||||||
|
];
|
||||||
|
|
||||||
|
const payloadTypeNameByCode: Record<string, string> = Object.entries(PayloadType).reduce(
|
||||||
|
(accumulator, [name, value]) => {
|
||||||
|
accumulator[String(value)] = name.toLowerCase().replace(/_/g, ' ');
|
||||||
|
return accumulator;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>
|
||||||
|
);
|
||||||
|
|
||||||
|
const darkenHexColor = (hexColor: string, factor = 0.72): string => {
|
||||||
|
const normalized = hexColor.replace('#', '');
|
||||||
|
const toComponent = (start: number) => Number.parseInt(normalized.slice(start, start + 2), 16);
|
||||||
|
|
||||||
|
const red = Math.max(0, Math.min(255, Math.round(toComponent(0) * factor)));
|
||||||
|
const green = Math.max(0, Math.min(255, Math.round(toComponent(2) * factor)));
|
||||||
|
const blue = Math.max(0, Math.min(255, Math.round(toComponent(4) * factor)));
|
||||||
|
|
||||||
|
const toHex = (value: number) => value.toString(16).padStart(2, '0');
|
||||||
|
return `#${toHex(red)}${toHex(green)}${toHex(blue)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MeshCoreStatsView: React.FC = () => {
|
||||||
|
const [view, setView] = useState<MeshCoreStatsView>('daily');
|
||||||
|
const [originStats, setOriginStats] = useState<MeshCoreOriginStats | null>(null);
|
||||||
|
const [packetStats, setPacketStats] = useState<MeshCorePacketStats | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [originResponse, packetResponse] = await Promise.all([
|
||||||
|
meshCoreService.fetchOriginStats(view),
|
||||||
|
meshCoreService.fetchPacketStats(view),
|
||||||
|
]);
|
||||||
|
if (!isMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOriginStats(originResponse);
|
||||||
|
setPacketStats(packetResponse);
|
||||||
|
} catch (err) {
|
||||||
|
if (!isMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load origin statistics');
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void load();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [view]);
|
||||||
|
|
||||||
|
const xValues = useMemo(() => {
|
||||||
|
if (!originStats) {
|
||||||
|
return [] as number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
return originStats.timestamps;
|
||||||
|
}, [originStats]);
|
||||||
|
|
||||||
|
const formatTimestampLabel = (timestamp: number) => {
|
||||||
|
const dateOptions: Intl.DateTimeFormatOptions =
|
||||||
|
view === 'daily'
|
||||||
|
? { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }
|
||||||
|
: view === 'weekly'
|
||||||
|
? { month: 'short', day: 'numeric', hour: '2-digit' }
|
||||||
|
: { month: 'short', day: 'numeric' };
|
||||||
|
|
||||||
|
return new Date(timestamp * 1000).toLocaleString(undefined, dateOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
const origins = useMemo(() => {
|
||||||
|
if (!originStats) {
|
||||||
|
return [] as string[];
|
||||||
|
}
|
||||||
|
return Object.keys(originStats.signal).sort((a, b) => a.localeCompare(b));
|
||||||
|
}, [originStats]);
|
||||||
|
|
||||||
|
const originColorMap = useMemo(() => {
|
||||||
|
const colorMap: Record<string, { snr: string; rssi: string }> = {};
|
||||||
|
origins.forEach((origin, index) => {
|
||||||
|
const snrColor = originPalette[index % originPalette.length];
|
||||||
|
colorMap[origin] = {
|
||||||
|
snr: snrColor,
|
||||||
|
rssi: darkenHexColor(snrColor),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return colorMap;
|
||||||
|
}, [origins]);
|
||||||
|
|
||||||
|
const series = useMemo(() => {
|
||||||
|
if (!originStats) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return origins
|
||||||
|
.map((origin) => ({
|
||||||
|
label: origin || 'unknown',
|
||||||
|
data: originStats.counts[origin],
|
||||||
|
stack: 'count',
|
||||||
|
color: originColorMap[origin]?.snr,
|
||||||
|
}));
|
||||||
|
}, [originStats, origins, originColorMap]);
|
||||||
|
|
||||||
|
const signalSeries = useMemo(() => {
|
||||||
|
if (!originStats) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = origins.map((origin) => [origin, originStats.signal[origin]] as const);
|
||||||
|
|
||||||
|
const snrSeries = entries.map(([origin, data]) => ({
|
||||||
|
label: `SNR · ${origin || 'unknown'}`,
|
||||||
|
data: data.snr,
|
||||||
|
showMark: false,
|
||||||
|
yAxisId: 'snr',
|
||||||
|
color: originColorMap[origin]?.snr,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const rssiSeries = entries.map(([origin, data]) => ({
|
||||||
|
label: `RSSI · ${origin || 'unknown'}`,
|
||||||
|
data: data.rssi,
|
||||||
|
showMark: false,
|
||||||
|
yAxisId: 'rssi',
|
||||||
|
color: originColorMap[origin]?.rssi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [...snrSeries, ...rssiSeries];
|
||||||
|
}, [originStats, origins, originColorMap]);
|
||||||
|
|
||||||
|
const totalPackets = useMemo(() => {
|
||||||
|
if (!originStats) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(originStats.counts).reduce((sum, entry) => {
|
||||||
|
return sum + entry.reduce((innerSum, value) => innerSum + value, 0);
|
||||||
|
}, 0);
|
||||||
|
}, [originStats]);
|
||||||
|
|
||||||
|
const routingCombinedSeries = useMemo(() => {
|
||||||
|
if (!packetStats) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const showRoutingBarLabels = xValues.length <= 20;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'routing-flood',
|
||||||
|
type: 'bar' as const,
|
||||||
|
label: 'Flood',
|
||||||
|
data: packetStats.routing.flood,
|
||||||
|
color: '#40A9FF',
|
||||||
|
stack: 'routing',
|
||||||
|
yAxisId: 'routing',
|
||||||
|
barLabel: showRoutingBarLabels ? ('value' as const) : undefined,
|
||||||
|
barLabelPlacement: 'outside' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'routing-direct',
|
||||||
|
type: 'bar' as const,
|
||||||
|
label: 'Direct',
|
||||||
|
data: packetStats.routing.direct,
|
||||||
|
color: '#73D13D',
|
||||||
|
stack: 'routing',
|
||||||
|
yAxisId: 'routing',
|
||||||
|
barLabel: showRoutingBarLabels ? ('value' as const) : undefined,
|
||||||
|
barLabelPlacement: 'outside' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'routing-transport',
|
||||||
|
type: 'line' as const,
|
||||||
|
label: 'Transport codes',
|
||||||
|
data: packetStats.routing.transport,
|
||||||
|
color: '#FAAD14',
|
||||||
|
showMark: xValues.length <= 30,
|
||||||
|
yAxisId: 'transport',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [packetStats, xValues.length]);
|
||||||
|
|
||||||
|
const routingYAxisMax = useMemo(() => {
|
||||||
|
if (!packetStats) {
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxValue = Math.max(
|
||||||
|
...packetStats.routing.flood,
|
||||||
|
...packetStats.routing.direct,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return Math.max(10, Math.ceil(maxValue * 1.2));
|
||||||
|
}, [packetStats]);
|
||||||
|
|
||||||
|
const transportYAxisMax = useMemo(() => {
|
||||||
|
if (!packetStats) {
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxValue = Math.max(...packetStats.routing.transport, 0);
|
||||||
|
return Math.max(10, Math.ceil(maxValue * 1.15));
|
||||||
|
}, [packetStats]);
|
||||||
|
|
||||||
|
const xTickStep = view === 'daily' ? 4 : view === 'weekly' ? 3 : 1;
|
||||||
|
const xTickStyle =
|
||||||
|
view === 'monthly'
|
||||||
|
? { fontSize: 11 }
|
||||||
|
: { angle: -35, textAnchor: 'end' as const, fontSize: 11 };
|
||||||
|
|
||||||
|
const payloadSeries = useMemo(() => {
|
||||||
|
if (!packetStats) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(packetStats.payload)
|
||||||
|
.sort(([a], [b]) => Number(a) - Number(b))
|
||||||
|
.map(([payloadType, data], idx) => ({
|
||||||
|
label: payloadTypeNameByCode[payloadType] ?? `type ${payloadType}`,
|
||||||
|
data,
|
||||||
|
stack: 'payload',
|
||||||
|
color: originPalette[idx % originPalette.length],
|
||||||
|
}));
|
||||||
|
}, [packetStats]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VerticalSplit
|
||||||
|
ratio="25:75"
|
||||||
|
left={(
|
||||||
|
<Card className="data-table-card h-100 d-flex flex-column">
|
||||||
|
<Card.Header className="data-table-header">Origin Stats</Card.Header>
|
||||||
|
<Card.Body className="data-table-body d-flex flex-column gap-3">
|
||||||
|
<Form.Group controlId="meshcore-origin-stats-view">
|
||||||
|
<Form.Label>Time Window</Form.Label>
|
||||||
|
<Form.Select
|
||||||
|
value={view}
|
||||||
|
onChange={(event) => setView(event.target.value as MeshCoreStatsView)}
|
||||||
|
aria-label="Select MeshCore origin stats time window"
|
||||||
|
>
|
||||||
|
{(Object.keys(viewLabelByKey) as MeshCoreStatsView[]).map((key) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{viewLabelByKey[key]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Form.Select>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<div className="meshcore-origin-stats-summary">
|
||||||
|
<div className="text-secondary">{descriptionByView[view]}</div>
|
||||||
|
<div><strong>Origins:</strong> {originStats ? Object.keys(originStats.signal).length : 0}</div>
|
||||||
|
<div><strong>Buckets:</strong> {originStats ? originStats.timestamps.length : 0}</div>
|
||||||
|
<div><strong>Total Packets:</strong> {totalPackets}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="d-flex align-items-center gap-2 text-secondary">
|
||||||
|
<Spinner animation="border" size="sm" />
|
||||||
|
Loading statistics...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="danger" className="mb-0">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ButtonGroup size="sm" aria-label="Select time window" className="meshcore-origin-stats-buttons">
|
||||||
|
{(Object.keys(viewLabelByKey) as MeshCoreStatsView[]).map((key) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
className={`btn ${view === key ? 'btn-primary' : 'btn-outline-light'}`}
|
||||||
|
onClick={() => setView(key)}
|
||||||
|
>
|
||||||
|
{viewLabelByKey[key]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</ButtonGroup>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
right={(
|
||||||
|
<Card className="data-table-card h-100 d-flex flex-column">
|
||||||
|
<Card.Header className="data-table-header">Origin & Packet Statistics</Card.Header>
|
||||||
|
<Card.Body className="data-table-body meshcore-stats-body d-flex flex-column p-2 gap-3">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="d-flex h-100 align-items-center justify-content-center text-secondary">
|
||||||
|
<Spinner animation="border" className="me-2" />
|
||||||
|
Loading chart...
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isLoading && !error && originStats && packetStats && xValues.length > 0 && signalSeries.length > 0 && series.length > 0 ? (
|
||||||
|
<div className="meshcore-origin-stats-chart h-100">
|
||||||
|
<div className="meshcore-stats-columns">
|
||||||
|
<div className="meshcore-stats-column">
|
||||||
|
<h6 className="mb-1">Origin Stats</h6>
|
||||||
|
|
||||||
|
<div className="meshcore-stats-section">
|
||||||
|
<h6 className="mb-2">Average Signal Strength (SNR dB / RSSI dBm)</h6>
|
||||||
|
<div className="meshcore-chart-with-legend">
|
||||||
|
<div className="meshcore-chart-area">
|
||||||
|
<LineChart
|
||||||
|
height={260}
|
||||||
|
hideLegend
|
||||||
|
xAxis={[
|
||||||
|
{
|
||||||
|
data: xValues,
|
||||||
|
scaleType: 'point',
|
||||||
|
valueFormatter: (value) => formatTimestampLabel(Number(value)),
|
||||||
|
tickLabelInterval: (_, index) => index % xTickStep === 0,
|
||||||
|
tickLabelStyle: xTickStyle,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
yAxis={[
|
||||||
|
{
|
||||||
|
id: 'snr',
|
||||||
|
width: 56,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rssi',
|
||||||
|
position: 'right',
|
||||||
|
width: 56,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
series={signalSeries}
|
||||||
|
margin={{ top: 20, right: 16, bottom: 70, left: 16 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="meshcore-custom-legend">
|
||||||
|
<div className="meshcore-custom-legend-title">Legend</div>
|
||||||
|
<ListGroup variant="flush" className="meshcore-custom-legend-list">
|
||||||
|
{origins.map((origin) => (
|
||||||
|
<ListGroup.Item key={`signal-legend-${origin}`} className="meshcore-custom-legend-item">
|
||||||
|
<div className="meshcore-custom-legend-origin">{origin || 'unknown'}</div>
|
||||||
|
<div className="meshcore-custom-legend-metrics">
|
||||||
|
<span className="meshcore-swatch" style={{ backgroundColor: originColorMap[origin]?.snr }} />
|
||||||
|
<span className="small">SNR</span>
|
||||||
|
<span className="meshcore-swatch ms-2" style={{ backgroundColor: originColorMap[origin]?.rssi }} />
|
||||||
|
<span className="small">RSSI</span>
|
||||||
|
</div>
|
||||||
|
</ListGroup.Item>
|
||||||
|
))}
|
||||||
|
</ListGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="meshcore-stats-section">
|
||||||
|
<h6 className="mb-2">Packet Count per Origin</h6>
|
||||||
|
<div className="meshcore-chart-with-legend">
|
||||||
|
<div className="meshcore-chart-area">
|
||||||
|
<BarChart
|
||||||
|
height={260}
|
||||||
|
hideLegend
|
||||||
|
xAxis={[
|
||||||
|
{
|
||||||
|
data: xValues,
|
||||||
|
scaleType: 'band',
|
||||||
|
valueFormatter: (value) => formatTimestampLabel(Number(value)),
|
||||||
|
tickLabelInterval: (_, index) => index % xTickStep === 0,
|
||||||
|
tickLabelStyle: xTickStyle,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
yAxis={[
|
||||||
|
{
|
||||||
|
id: 'count',
|
||||||
|
width: 56,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'count-right-spacer',
|
||||||
|
position: 'right',
|
||||||
|
width: 56,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
series={series.map((item) => ({ ...item, yAxisId: 'count' }))}
|
||||||
|
margin={{ top: 20, right: 16, bottom: 82, left: 16 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="meshcore-custom-legend">
|
||||||
|
<div className="meshcore-custom-legend-title">Origins</div>
|
||||||
|
<ListGroup variant="flush" className="meshcore-custom-legend-list">
|
||||||
|
{origins.map((origin) => (
|
||||||
|
<ListGroup.Item key={`count-legend-${origin}`} className="meshcore-custom-legend-item d-flex align-items-center gap-2">
|
||||||
|
<span className="meshcore-swatch" style={{ backgroundColor: originColorMap[origin]?.snr }} />
|
||||||
|
<span>{origin || 'unknown'}</span>
|
||||||
|
</ListGroup.Item>
|
||||||
|
))}
|
||||||
|
</ListGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="meshcore-stats-column">
|
||||||
|
<h6 className="mb-1">Packet Stats</h6>
|
||||||
|
|
||||||
|
<div className="meshcore-stats-section">
|
||||||
|
<h6 className="mb-2">Routing Types (Flood/Direct) with Transport Code Usage</h6>
|
||||||
|
<div className="meshcore-chart-with-legend">
|
||||||
|
<div className="meshcore-chart-area">
|
||||||
|
<ChartContainer
|
||||||
|
height={420}
|
||||||
|
series={routingCombinedSeries}
|
||||||
|
xAxis={[
|
||||||
|
{
|
||||||
|
id: 'routing-time',
|
||||||
|
data: xValues,
|
||||||
|
scaleType: 'band',
|
||||||
|
categoryGapRatio: 0.08,
|
||||||
|
barGapRatio: 0.08,
|
||||||
|
valueFormatter: (value) => formatTimestampLabel(Number(value)),
|
||||||
|
tickLabelInterval: (_, index) => index % xTickStep === 0,
|
||||||
|
tickLabelStyle: xTickStyle,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
yAxis={[
|
||||||
|
{
|
||||||
|
id: 'routing',
|
||||||
|
label: 'Packets',
|
||||||
|
min: 0,
|
||||||
|
max: routingYAxisMax,
|
||||||
|
tickNumber: 6,
|
||||||
|
width: 56,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'transport',
|
||||||
|
label: 'Transport code',
|
||||||
|
position: 'right',
|
||||||
|
min: 0,
|
||||||
|
max: transportYAxisMax,
|
||||||
|
tickNumber: 6,
|
||||||
|
width: 56,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
margin={{ top: 20, right: 16, bottom: 82, left: 16 }}
|
||||||
|
>
|
||||||
|
<BarPlot />
|
||||||
|
<LinePlot />
|
||||||
|
<MarkPlot />
|
||||||
|
<ChartsXAxis axisId="routing-time" />
|
||||||
|
<ChartsYAxis axisId="routing" />
|
||||||
|
<ChartsYAxis axisId="transport" />
|
||||||
|
<ChartsTooltip trigger="axis" />
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="meshcore-custom-legend">
|
||||||
|
<div className="meshcore-custom-legend-title">Routing</div>
|
||||||
|
<ListGroup variant="flush" className="meshcore-custom-legend-list">
|
||||||
|
<ListGroup.Item className="meshcore-custom-legend-item d-flex align-items-center gap-2">
|
||||||
|
<span className="meshcore-swatch" style={{ backgroundColor: '#40A9FF' }} />
|
||||||
|
<span>Flood</span>
|
||||||
|
</ListGroup.Item>
|
||||||
|
<ListGroup.Item className="meshcore-custom-legend-item d-flex align-items-center gap-2">
|
||||||
|
<span className="meshcore-swatch" style={{ backgroundColor: '#73D13D' }} />
|
||||||
|
<span>Direct</span>
|
||||||
|
</ListGroup.Item>
|
||||||
|
<ListGroup.Item className="meshcore-custom-legend-item d-flex align-items-center gap-2">
|
||||||
|
<span className="meshcore-swatch" style={{ backgroundColor: '#FAAD14' }} />
|
||||||
|
<span>Transport codes</span>
|
||||||
|
</ListGroup.Item>
|
||||||
|
</ListGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="meshcore-stats-section">
|
||||||
|
<h6 className="mb-2">Payload Types</h6>
|
||||||
|
<div className="meshcore-chart-with-legend">
|
||||||
|
<div className="meshcore-chart-area">
|
||||||
|
<BarChart
|
||||||
|
height={240}
|
||||||
|
hideLegend
|
||||||
|
xAxis={[
|
||||||
|
{
|
||||||
|
data: xValues,
|
||||||
|
scaleType: 'band',
|
||||||
|
valueFormatter: (value) => formatTimestampLabel(Number(value)),
|
||||||
|
tickLabelInterval: (_, index) => index % xTickStep === 0,
|
||||||
|
tickLabelStyle: xTickStyle,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
yAxis={[
|
||||||
|
{
|
||||||
|
id: 'payload',
|
||||||
|
width: 56,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'payload-right-spacer',
|
||||||
|
position: 'right',
|
||||||
|
width: 56,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
series={payloadSeries.map((item) => ({ ...item, yAxisId: 'payload' }))}
|
||||||
|
margin={{ top: 20, right: 16, bottom: 82, left: 16 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="meshcore-custom-legend">
|
||||||
|
<div className="meshcore-custom-legend-title">Payload</div>
|
||||||
|
<ListGroup variant="flush" className="meshcore-custom-legend-list">
|
||||||
|
{payloadSeries.map((item) => (
|
||||||
|
<ListGroup.Item key={`payload-legend-${item.label}`} className="meshcore-custom-legend-item d-flex align-items-center gap-2">
|
||||||
|
<span className="meshcore-swatch" style={{ backgroundColor: item.color }} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</ListGroup.Item>
|
||||||
|
))}
|
||||||
|
</ListGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isLoading && !error && (!originStats || !packetStats || xValues.length === 0 || series.length === 0) ? (
|
||||||
|
<div className="d-flex h-100 align-items-center justify-content-center text-secondary">
|
||||||
|
No origin statistics available for this time window.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MeshCoreStatsView;
|
||||||
516
ui/src/pages/meshcore/MeshCoreView.tsx
Normal file
516
ui/src/pages/meshcore/MeshCoreView.tsx
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Alert, Badge, Card, Spinner } from 'react-bootstrap';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { KPICard } from '../../components/protocol/KPICard';
|
||||||
|
import { ProtocolHero } from '../../components/protocol/ProtocolHero';
|
||||||
|
import { RadioListItem } from '../../components/protocol/RadioListItem';
|
||||||
|
import { StatusPill } from '../../components/protocol/StatusPill';
|
||||||
|
import { useRadios } from '../../contexts/RadiosContext';
|
||||||
|
import API from '../../services/API';
|
||||||
|
import '../../styles/ProtocolBriefing.scss';
|
||||||
|
import MeshCoreServiceImpl, {
|
||||||
|
type MeshCoreOriginStats,
|
||||||
|
type MeshCorePacketStats,
|
||||||
|
} from '../../services/MeshCoreService';
|
||||||
|
import { PayloadType } from '../../types/protocol/meshcore.types';
|
||||||
|
|
||||||
|
const meshCoreService = new MeshCoreServiceImpl(API);
|
||||||
|
|
||||||
|
const payloadTypeNameByCode: Record<string, string> = Object.entries(PayloadType).reduce(
|
||||||
|
(accumulator, [name, value]) => {
|
||||||
|
accumulator[String(value)] = name.toLowerCase().replace(/_/g, ' ');
|
||||||
|
return accumulator;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>
|
||||||
|
);
|
||||||
|
|
||||||
|
const sumSeries = (values: number[]): number => values.reduce((sum, value) => sum + value, 0);
|
||||||
|
|
||||||
|
const getSignalHealth = (avgSnr: number): { label: string; tone: 'success' | 'warning' | 'danger' } => {
|
||||||
|
if (avgSnr >= 8) {
|
||||||
|
return { label: 'Strong', tone: 'success' };
|
||||||
|
}
|
||||||
|
if (avgSnr >= 4) {
|
||||||
|
return { label: 'Moderate', tone: 'warning' };
|
||||||
|
}
|
||||||
|
return { label: 'Weak', tone: 'danger' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRoutingBehavior = (floodPercent: number): { label: string; tone: 'success' | 'warning' | 'danger' } => {
|
||||||
|
if (floodPercent <= 45) {
|
||||||
|
return { label: 'Efficient', tone: 'success' };
|
||||||
|
}
|
||||||
|
if (floodPercent <= 70) {
|
||||||
|
return { label: 'Mixed', tone: 'warning' };
|
||||||
|
}
|
||||||
|
return { label: 'Flood-heavy', tone: 'danger' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const MeshCoreView: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { radios, loading: radiosLoading, error: radiosError } = useRadios();
|
||||||
|
const [originStats, setOriginStats] = useState<MeshCoreOriginStats | null>(null);
|
||||||
|
const [packetStats, setPacketStats] = useState<MeshCorePacketStats | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [originResponse, packetResponse] = await Promise.all([
|
||||||
|
meshCoreService.fetchOriginStats('weekly'),
|
||||||
|
meshCoreService.fetchPacketStats('weekly'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!isMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOriginStats(originResponse);
|
||||||
|
setPacketStats(packetResponse);
|
||||||
|
} catch (err) {
|
||||||
|
if (!isMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load MeshCore briefing metrics');
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void load();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const snapshot = useMemo(() => {
|
||||||
|
if (!originStats || !packetStats) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originNames = Object.keys(originStats.signal);
|
||||||
|
const originsCount = originNames.length;
|
||||||
|
|
||||||
|
let totalPackets = 0;
|
||||||
|
let snrTotal = 0;
|
||||||
|
let rssiTotal = 0;
|
||||||
|
let signalSamples = 0;
|
||||||
|
|
||||||
|
originNames.forEach((origin) => {
|
||||||
|
totalPackets += sumSeries(originStats.counts[origin] ?? []);
|
||||||
|
const signal = originStats.signal[origin];
|
||||||
|
if (!signal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
signal.snr.forEach((value) => {
|
||||||
|
snrTotal += value;
|
||||||
|
signalSamples += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
signal.rssi.forEach((value) => {
|
||||||
|
rssiTotal += value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const floodTotal = sumSeries(packetStats.routing.flood);
|
||||||
|
const directTotal = sumSeries(packetStats.routing.direct);
|
||||||
|
const routingTotal = floodTotal + directTotal;
|
||||||
|
|
||||||
|
const payloadTotals = Object.entries(packetStats.payload).map(([payloadType, values]) => ({
|
||||||
|
payloadType,
|
||||||
|
total: sumSeries(values),
|
||||||
|
}));
|
||||||
|
|
||||||
|
payloadTotals.sort((left, right) => right.total - left.total);
|
||||||
|
|
||||||
|
const topPayload = payloadTotals[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
buckets: originStats.timestamps.length,
|
||||||
|
originsCount,
|
||||||
|
totalPackets,
|
||||||
|
avgSnr: signalSamples > 0 ? snrTotal / signalSamples : 0,
|
||||||
|
avgRssi: signalSamples > 0 ? rssiTotal / signalSamples : 0,
|
||||||
|
floodPercent: routingTotal > 0 ? (floodTotal / routingTotal) * 100 : 0,
|
||||||
|
directPercent: routingTotal > 0 ? (directTotal / routingTotal) * 100 : 0,
|
||||||
|
topPayloadName: topPayload
|
||||||
|
? (payloadTypeNameByCode[topPayload.payloadType] ?? `type ${topPayload.payloadType}`)
|
||||||
|
: 'unknown',
|
||||||
|
topPayloadTotal: topPayload?.total ?? 0,
|
||||||
|
};
|
||||||
|
}, [originStats, packetStats]);
|
||||||
|
|
||||||
|
const signalHealth = snapshot ? getSignalHealth(snapshot.avgSnr) : null;
|
||||||
|
const routingBehavior = snapshot ? getRoutingBehavior(snapshot.floodPercent) : null;
|
||||||
|
|
||||||
|
const meshCoreRadios = useMemo(() => {
|
||||||
|
return radios
|
||||||
|
.filter((radio) => radio.protocol === 'meshcore')
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.is_online !== right.is_online) {
|
||||||
|
return left.is_online ? -1 : 1;
|
||||||
|
}
|
||||||
|
return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' });
|
||||||
|
});
|
||||||
|
}, [radios]);
|
||||||
|
|
||||||
|
const meshCoreOnlineCount = meshCoreRadios.filter((radio) => radio.is_online).length;
|
||||||
|
|
||||||
|
const openRadioPackets = (radioName: string) => {
|
||||||
|
navigate(`/meshcore/packets?radios=${encodeURIComponent(radioName)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="protocol-briefing">
|
||||||
|
<ProtocolHero
|
||||||
|
protocolBadges={[
|
||||||
|
{ label: 'MeshCore', variant: 'info' },
|
||||||
|
{ label: 'LoRa PHY', variant: 'secondary' },
|
||||||
|
{ label: 'Weekly Snapshot', variant: 'dark' },
|
||||||
|
]}
|
||||||
|
title="Operator Briefing"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<p className="mb-2">
|
||||||
|
MeshCore is a community-first digital mesh for amateur operators: low power, distributed,
|
||||||
|
and resilient when RF conditions are marginal or fixed infrastructure is unavailable.
|
||||||
|
</p>
|
||||||
|
<p className="mb-0">
|
||||||
|
Under the hood, links run on LoRa physical-layer radios, trading throughput for robust range
|
||||||
|
and better link margin. This page stays practical: path stability, relay pressure, and airtime behavior.
|
||||||
|
A LoRa modulation deep dive can follow later.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
logoSrc="/image/protocol/meshcore.png"
|
||||||
|
logoAlt="MeshCore"
|
||||||
|
logoLabel="MeshCore Protocol"
|
||||||
|
statusPills={
|
||||||
|
!isLoading && !error && snapshot && signalHealth && routingBehavior ? (
|
||||||
|
<>
|
||||||
|
<StatusPill label="Signal health" value={signalHealth.label} tone={signalHealth.tone} />
|
||||||
|
<StatusPill label="Routing behavior" value={routingBehavior.label} tone={routingBehavior.tone} />
|
||||||
|
</>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Card className="data-table-card">
|
||||||
|
<Card.Body className="d-flex align-items-center gap-2 text-secondary">
|
||||||
|
<Spinner animation="border" size="sm" />
|
||||||
|
Loading operator briefing…
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Alert variant="danger" className="mb-0">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isLoading && !error && snapshot ? (
|
||||||
|
<>
|
||||||
|
<div className="protocol-briefing-kpis">
|
||||||
|
<KPICard
|
||||||
|
label="Observed Origins"
|
||||||
|
value={snapshot.originsCount}
|
||||||
|
description="Unique source stations heard in the last 7 days"
|
||||||
|
/>
|
||||||
|
<KPICard
|
||||||
|
label="Total Packets"
|
||||||
|
value={snapshot.totalPackets.toLocaleString()}
|
||||||
|
description={`Across ${snapshot.buckets} time buckets`}
|
||||||
|
/>
|
||||||
|
<KPICard
|
||||||
|
label="Signal Envelope"
|
||||||
|
value={`${snapshot.avgSnr.toFixed(1)} dB / ${snapshot.avgRssi.toFixed(1)} dBm`}
|
||||||
|
description="Average link margin and received power envelope"
|
||||||
|
/>
|
||||||
|
<KPICard
|
||||||
|
label="Top Payload"
|
||||||
|
value={<span className="text-capitalize">{snapshot.topPayloadName}</span>}
|
||||||
|
description={`${snapshot.topPayloadTotal.toLocaleString()} packets`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="data-table-card">
|
||||||
|
<Card.Header className="data-table-header d-flex align-items-center justify-content-between gap-2">
|
||||||
|
<span>Registered MeshCore Radios</span>
|
||||||
|
<Badge bg="secondary">{meshCoreOnlineCount}/{meshCoreRadios.length} online</Badge>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body>
|
||||||
|
{radiosLoading ? (
|
||||||
|
<div className="d-flex align-items-center gap-2 text-secondary small">
|
||||||
|
<Spinner animation="border" size="sm" />
|
||||||
|
Loading radio registry…
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!radiosLoading && radiosError ? (
|
||||||
|
<Alert variant="warning" className="mb-0 py-2">
|
||||||
|
{radiosError}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!radiosLoading && !radiosError && meshCoreRadios.length === 0 ? (
|
||||||
|
<div className="text-secondary small">No radios registered for MeshCore.</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!radiosLoading && !radiosError && meshCoreRadios.length > 0 ? (
|
||||||
|
<div className="protocol-briefing-radio-list">
|
||||||
|
{meshCoreRadios.map((radio) => (
|
||||||
|
<RadioListItem key={radio.id} radio={radio} onRadioClick={openRadioPackets} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="protocol-briefing-columns">
|
||||||
|
<Card className="data-table-card">
|
||||||
|
<Card.Header className="data-table-header">How to Read This Network</Card.Header>
|
||||||
|
<Card.Body>
|
||||||
|
<ul className="protocol-briefing-list mb-0">
|
||||||
|
<li><strong>Origins:</strong> quick proxy for station diversity and net activity spread.</li>
|
||||||
|
<li><strong>SNR/RSSI:</strong> track link margin trends and near-term path stability.</li>
|
||||||
|
<li><strong>Routing mix:</strong> flood-heavy windows can indicate higher relay load or weak direct paths.</li>
|
||||||
|
<li><strong>Payload profile:</strong> shows airtime usage pattern (telemetry, chat, or control traffic).</li>
|
||||||
|
</ul>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="data-table-card">
|
||||||
|
<Card.Header className="data-table-header">Routing Snapshot</Card.Header>
|
||||||
|
<Card.Body>
|
||||||
|
<div className="meshcore-briefing-route-row">
|
||||||
|
<span>Flood traffic</span>
|
||||||
|
<strong>{snapshot.floodPercent.toFixed(1)}%</strong>
|
||||||
|
</div>
|
||||||
|
<div className="meshcore-briefing-route-bar">
|
||||||
|
<span style={{ width: `${snapshot.floodPercent}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="meshcore-briefing-route-row mt-3">
|
||||||
|
<span>Direct traffic</span>
|
||||||
|
<strong>{snapshot.directPercent.toFixed(1)}%</strong>
|
||||||
|
</div>
|
||||||
|
<div className="meshcore-briefing-route-bar meshcore-briefing-route-bar--direct">
|
||||||
|
<span style={{ width: `${snapshot.directPercent}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="text-secondary small mt-3 mb-0">
|
||||||
|
Quick interpretation: a balanced split usually indicates stable neighborhood reachability and lower relay pressure.
|
||||||
|
</p>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="data-table-card meshcore-briefing-lora-card">
|
||||||
|
<Card.Header className="data-table-header d-flex align-items-center justify-content-between gap-2">
|
||||||
|
<span>LoRa in This Context</span>
|
||||||
|
<Badge bg="warning" text="dark">Regulatory note: check local rules</Badge>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body>
|
||||||
|
<div className="meshcore-briefing-lora-grid">
|
||||||
|
<div>
|
||||||
|
<div className="meshcore-briefing-kpi-label">Current RF profile</div>
|
||||||
|
<p className="small text-secondary mb-0">
|
||||||
|
868.618 MHz center frequency, LoRa SF8, CR8, and 62.5 kHz bandwidth.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="meshcore-briefing-kpi-label">What it gives you</div>
|
||||||
|
<p className="small text-secondary mb-0">
|
||||||
|
Long reach, better weak-signal decode behavior, and practical operation at low power.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="meshcore-briefing-kpi-label">Operational tradeoff</div>
|
||||||
|
<p className="small text-secondary mb-0">
|
||||||
|
Lower throughput, so routing efficiency and payload discipline matter when airtime is busy.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="meshcore-briefing-kpi-label">What to monitor first</div>
|
||||||
|
<p className="small text-secondary mb-0">
|
||||||
|
Origin diversity, flood/direct split, and sustained SNR/RSSI drift across the window.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="meshcore-briefing-kpi-label">ISM frequencies and licensing</div>
|
||||||
|
<p className="small text-secondary mb-0">
|
||||||
|
The 868 MHz ISM band is generally intended for license-exempt low-power devices, so unlicensed operators can use
|
||||||
|
compliant LoRa equipment on ISM channels where regional rules permit. Amateur-service operation is separate and still
|
||||||
|
requires an amateur radio license, callsign use, and local band-plan compliance.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="data-table-card meshcore-briefing-ops">
|
||||||
|
<Card.Header className="data-table-header">How MeshCore Operates on RF</Card.Header>
|
||||||
|
<Card.Body>
|
||||||
|
<p className="meshcore-briefing-ops-lead mb-2">
|
||||||
|
MeshCore behaves like a cooperative RF neighborhood. Instead of one fixed path, packets move hop by hop through nearby nodes,
|
||||||
|
and the network converges toward delivery using direct and flood-style forwarding where needed.
|
||||||
|
</p>
|
||||||
|
<p className="text-secondary mb-3">
|
||||||
|
In practice, quality is governed by geometry, channel conditions, and airtime pressure. When conditions are favorable,
|
||||||
|
delivery tends to stay direct. When links degrade or neighborhoods become sparse, forwarding expands and repeater participation
|
||||||
|
becomes more visible in routing statistics.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h6 className="meshcore-briefing-ops-title">Known MeshCore Node Types</h6>
|
||||||
|
<div className="meshcore-briefing-node-types mb-3">
|
||||||
|
<div className="meshcore-briefing-role-card">
|
||||||
|
<div className="meshcore-briefing-kpi-label">Chat Node</div>
|
||||||
|
<p className="small text-secondary mb-0">General endpoint for operator traffic such as messaging and routine interactive use.</p>
|
||||||
|
</div>
|
||||||
|
<div className="meshcore-briefing-role-card">
|
||||||
|
<div className="meshcore-briefing-kpi-label">Repeater</div>
|
||||||
|
<p className="small text-secondary mb-0">Coverage extension node that improves neighborhood reachability across RF gaps.</p>
|
||||||
|
</div>
|
||||||
|
<div className="meshcore-briefing-role-card">
|
||||||
|
<div className="meshcore-briefing-kpi-label">Room Server</div>
|
||||||
|
<p className="small text-secondary mb-0">Service-oriented node used for room/group coordination and shared interaction points.</p>
|
||||||
|
</div>
|
||||||
|
<div className="meshcore-briefing-role-card">
|
||||||
|
<div className="meshcore-briefing-kpi-label">Sensor</div>
|
||||||
|
<p className="small text-secondary mb-0">Telemetry-focused node optimized for periodic sensing and lightweight data reporting.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-secondary mb-3">
|
||||||
|
Note on data interpretation: <strong>Unknown</strong> in packet metadata is a temporary decode/classification state,
|
||||||
|
not a real operational node role.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h6 className="meshcore-briefing-ops-title">Packet Journey Across RF</h6>
|
||||||
|
<p className="text-secondary mb-2">
|
||||||
|
A source node emits a frame into its local neighborhood. Nearby peers evaluate whether the destination is directly reachable;
|
||||||
|
if yes, the packet stays on the shortest practical RF path. If not, forwarding broadens until connectivity is re-established.
|
||||||
|
</p>
|
||||||
|
<p className="text-secondary mb-3">
|
||||||
|
Duplicate suppression prevents runaway retransmission. The practical outcome is convergence: multiple candidate paths may be heard,
|
||||||
|
but the network settles on useful delivery paths as conditions evolve.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="meshcore-briefing-kpi-label">Reference topology</div>
|
||||||
|
<pre className="meshcore-briefing-ascii" aria-label="MeshCore RF routing reference diagram">
|
||||||
|
{` [Chat Node A] -> [Repeater N] <=> [Repeater S] -> [Chat Node B]
|
||||||
|
| | |
|
||||||
|
[Sensor] [Room Server] [Sensor]
|
||||||
|
|
||||||
|
direct-dominant window => stronger local geometry, lower forwarding pressure
|
||||||
|
flood-dominant window => weaker links / topology transition / denser RF contention`}
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<p className="text-secondary mt-3 mb-0">
|
||||||
|
Operator cue: if flood ratio climbs while SNR trends downward, treat it as a path-quality event first.
|
||||||
|
If payload volume rises while direct ratio remains stable, that usually indicates a traffic-shape shift more than RF degradation.
|
||||||
|
</p>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="data-table-card meshcore-briefing-layers">
|
||||||
|
<Card.Header className="data-table-header">MeshCore Protocol Layers vs OSI</Card.Header>
|
||||||
|
<Card.Body>
|
||||||
|
<p className="meshcore-briefing-ops-lead mb-2">
|
||||||
|
MeshCore is not a strict seven-layer OSI implementation, but operators can map its behavior to OSI concepts very effectively.
|
||||||
|
This mapping helps explain where RF ends, where forwarding decisions start, and where application payload meaning appears.
|
||||||
|
</p>
|
||||||
|
<p className="text-secondary mb-3">
|
||||||
|
A practical way to read the protocol is as a compact four-layer model (Physical, Link, Network, Application)
|
||||||
|
and then map those functions to their nearest OSI equivalents.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="meshcore-briefing-layer-map" role="table" aria-label="MeshCore to OSI layer mapping">
|
||||||
|
<div className="meshcore-briefing-layer-row" role="row">
|
||||||
|
<div className="meshcore-briefing-layer-name" role="cell">
|
||||||
|
<div className="meshcore-briefing-kpi-label">MeshCore Layer</div>
|
||||||
|
<strong>Physical Layer (LoRa PHY)</strong>
|
||||||
|
</div>
|
||||||
|
<div className="meshcore-briefing-layer-desc" role="cell">
|
||||||
|
RF modulation and airtime profile: 868.618 MHz, SF8, CR8, 62.5 kHz bandwidth.
|
||||||
|
</div>
|
||||||
|
<div className="meshcore-briefing-layer-osi" role="cell">OSI: L1 Physical</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="meshcore-briefing-layer-row" role="row">
|
||||||
|
<div className="meshcore-briefing-layer-name" role="cell">
|
||||||
|
<strong>Link Layer (local delivery behavior)</strong>
|
||||||
|
</div>
|
||||||
|
<div className="meshcore-briefing-layer-desc" role="cell">
|
||||||
|
Neighbor-to-neighbor frame exchange, retries, and practical channel access constraints on shared RF.
|
||||||
|
</div>
|
||||||
|
<div className="meshcore-briefing-layer-osi" role="cell">OSI: L2 Data Link</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="meshcore-briefing-layer-row" role="row">
|
||||||
|
<div className="meshcore-briefing-layer-name" role="cell">
|
||||||
|
<strong>Network Layer (mesh routing)</strong>
|
||||||
|
</div>
|
||||||
|
<div className="meshcore-briefing-layer-desc" role="cell">
|
||||||
|
Route selection and forwarding via `DIRECT` / `FLOOD` with transport modes like `TRANSPORT_DIRECT` and `TRANSPORT_FLOOD`.
|
||||||
|
</div>
|
||||||
|
<div className="meshcore-briefing-layer-osi" role="cell">OSI: L3 (+ part of L4 semantics)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="meshcore-briefing-layer-row" role="row">
|
||||||
|
<div className="meshcore-briefing-layer-name" role="cell">
|
||||||
|
<strong>Application Layer (message semantics)</strong>
|
||||||
|
</div>
|
||||||
|
<div className="meshcore-briefing-layer-desc" role="cell">
|
||||||
|
Payload types define meaning: `TEXT`, `GROUP_TEXT`, `CONTROL`, `PATH`, `ACK`, `ADVERT`, etc.
|
||||||
|
</div>
|
||||||
|
<div className="meshcore-briefing-layer-osi" role="cell">OSI: L7 (with L5/L6-like data handling)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 className="meshcore-briefing-ops-title mt-3">Concrete OSI-style Examples</h6>
|
||||||
|
<div className="meshcore-briefing-examples">
|
||||||
|
<div className="meshcore-briefing-example-row">
|
||||||
|
<div className="meshcore-briefing-example-label">Example A · Group chat message</div>
|
||||||
|
<p className="text-secondary mb-0">
|
||||||
|
A `GROUP_TEXT` payload carries user content (OSI L7 meaning), while route type and transport flags determine whether
|
||||||
|
the frame moves directly or via flood-assisted forwarding (OSI L3/L4 behavior over RF).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="meshcore-briefing-example-row">
|
||||||
|
<div className="meshcore-briefing-example-label">Example B · Path visibility</div>
|
||||||
|
<p className="text-secondary mb-0">
|
||||||
|
A path-bearing packet (`PATH`/routing metadata) is primarily network-layer insight (OSI L3 analogue): it explains
|
||||||
|
how the packet traversed the mesh, independent of the user payload meaning.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="meshcore-briefing-example-row">
|
||||||
|
<div className="meshcore-briefing-example-label">Example C · RF degradation event</div>
|
||||||
|
<p className="text-secondary mb-0">
|
||||||
|
If RSSI/SNR drop (OSI L1 symptoms), you often observe increased flood ratio and fewer stable direct paths (OSI L3/L4
|
||||||
|
adaptation). Application traffic still exists, but efficiency and latency profile change.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MeshCoreView;
|
||||||
358
ui/src/protocols/adsb.ts
Normal file
358
ui/src/protocols/adsb.ts
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
import type {
|
||||||
|
Frame as IFrame,
|
||||||
|
DecodedPayload,
|
||||||
|
IdentificationPayload,
|
||||||
|
VelocityPayload,
|
||||||
|
SurfacePositionPayload,
|
||||||
|
AltitudePayload,
|
||||||
|
} from '../types/protocol/adsb.types';
|
||||||
|
import type { Segment } from '../types/protocol/dissection.types';
|
||||||
|
import { ADSBMessageType, ADSBTypeCode } from '../types/protocol/adsb.types';
|
||||||
|
|
||||||
|
export class ADSBFrame implements IFrame {
|
||||||
|
messageType: number = 0;
|
||||||
|
icao?: string;
|
||||||
|
typeCode?: number;
|
||||||
|
payload: DecodedPayload = { type: 'identification' };
|
||||||
|
raw: Uint8Array = new Uint8Array();
|
||||||
|
crc?: number;
|
||||||
|
segments?: Segment[];
|
||||||
|
|
||||||
|
private downlinkFormat: number = 0;
|
||||||
|
private data: Uint8Array = new Uint8Array();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse raw ADSB message
|
||||||
|
*/
|
||||||
|
parse(raw: Uint8Array): void {
|
||||||
|
if (raw.length < 7) {
|
||||||
|
throw new Error('ADSB message too short');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.raw = raw;
|
||||||
|
this.data = raw;
|
||||||
|
|
||||||
|
// Parse downlink format (first 5 bits)
|
||||||
|
this.downlinkFormat = (raw[0] >> 3) & 0x1f;
|
||||||
|
this.messageType = this.downlinkFormat;
|
||||||
|
|
||||||
|
// Parse ICAO address (24 bits, different positions based on DF)
|
||||||
|
this.icao = this.extractICAO();
|
||||||
|
|
||||||
|
// Parse based on downlink format
|
||||||
|
switch (this.downlinkFormat) {
|
||||||
|
case ADSBMessageType.IDENTIFICATION_REPLY:
|
||||||
|
case ADSBMessageType.SURVEILLANCE_REPLY:
|
||||||
|
// DF4/5: Short messages with limited data
|
||||||
|
this.parseShortMessage();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ADSBMessageType.IDENTIFICATION:
|
||||||
|
this.parseIdentification();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ADSBMessageType.SURVEILLANCE_POSITION:
|
||||||
|
case ADSBMessageType.SURVEILLANCE_POSITION_ALT:
|
||||||
|
// DF17/18: Extended squitter with type code
|
||||||
|
this.parseExtendedSquitter();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
this.payload = { type: 'identification' };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode the parsed ADSB message
|
||||||
|
*/
|
||||||
|
decode(): DecodedPayload {
|
||||||
|
return this.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ICAO address as hex string
|
||||||
|
*/
|
||||||
|
private extractICAO(): string | undefined {
|
||||||
|
if (this.downlinkFormat === ADSBMessageType.IDENTIFICATION) {
|
||||||
|
// DF11: ICAO in bits 8-31
|
||||||
|
if (this.data.length >= 4) {
|
||||||
|
const addr = ((this.data[1] & 0xff) << 16) | ((this.data[2] & 0xff) << 8) | (this.data[3] & 0xff);
|
||||||
|
return addr.toString(16).padStart(6, '0').toUpperCase();
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
this.downlinkFormat === ADSBMessageType.SURVEILLANCE_POSITION ||
|
||||||
|
this.downlinkFormat === ADSBMessageType.SURVEILLANCE_POSITION_ALT
|
||||||
|
) {
|
||||||
|
// DF17/18: ICAO in bytes 1-3
|
||||||
|
if (this.data.length >= 4) {
|
||||||
|
const addr = ((this.data[1] & 0xff) << 16) | ((this.data[2] & 0xff) << 8) | (this.data[3] & 0xff);
|
||||||
|
return addr.toString(16).padStart(6, '0').toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse short message (DF4/5)
|
||||||
|
*/
|
||||||
|
private parseShortMessage(): void {
|
||||||
|
const payload: IdentificationPayload = {
|
||||||
|
type: 'identification',
|
||||||
|
icao: this.icao,
|
||||||
|
};
|
||||||
|
this.payload = payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse identification message (DF11)
|
||||||
|
*/
|
||||||
|
private parseIdentification(): void {
|
||||||
|
const payload: IdentificationPayload = {
|
||||||
|
type: 'identification',
|
||||||
|
icao: this.icao,
|
||||||
|
};
|
||||||
|
this.payload = payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse extended squitter (DF17/18)
|
||||||
|
*/
|
||||||
|
private parseExtendedSquitter(): void {
|
||||||
|
if (this.data.length < 14) {
|
||||||
|
this.payload = { type: 'identification', icao: this.icao };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type code is in bits 0-4 of byte 4
|
||||||
|
this.typeCode = (this.data[4] >> 3) & 0x1f;
|
||||||
|
|
||||||
|
switch (this.typeCode) {
|
||||||
|
case ADSBTypeCode.IDENTIFICATION_AND_CATEGORY:
|
||||||
|
this.parseIdentificationAndCategory();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ADSBTypeCode.SURFACE_POSITION:
|
||||||
|
this.parseSurfacePosition();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ADSBTypeCode.ALTITUDE_BAROMETRIC:
|
||||||
|
case ADSBTypeCode.ALTITUDE_GEOMETRIC:
|
||||||
|
this.parseAltitude();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ADSBTypeCode.AIRBORNE_VELOCITY:
|
||||||
|
this.parseVelocity();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
this.payload = { type: 'identification', icao: this.icao };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse identification and category (TC 1)
|
||||||
|
*/
|
||||||
|
private parseIdentificationAndCategory(): void {
|
||||||
|
let callsign = '';
|
||||||
|
if (this.data.length >= 11) {
|
||||||
|
// Callsign is in bytes 5-10 (48 bits), 6 characters
|
||||||
|
const chars = 'US0-9#@ABCDEFGHIJKLMNOPQRSTUVWXYZ ';
|
||||||
|
const bits = this.extractBits(40, 48);
|
||||||
|
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const char = (bits >> (42 - i * 6)) & 0x3f;
|
||||||
|
if (char < chars.length) {
|
||||||
|
callsign += chars[char];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: IdentificationPayload = {
|
||||||
|
type: 'identification',
|
||||||
|
icao: this.icao,
|
||||||
|
callsign: callsign.trim() || undefined,
|
||||||
|
category: this.getCategoryString((this.data[4] >> 0) & 0x07),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.payload = payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse surface position (TC 5-8)
|
||||||
|
*/
|
||||||
|
private parseSurfacePosition(): void {
|
||||||
|
const latitude = this.extractLatitude(40);
|
||||||
|
const longitude = this.extractLongitude(57);
|
||||||
|
|
||||||
|
const payload: SurfacePositionPayload = {
|
||||||
|
type: 'surface-position',
|
||||||
|
icao: this.icao,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
groundSpeed: this.extractGroundSpeed(46),
|
||||||
|
trackAngle: this.extractTrackAngle(52),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.payload = payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse barometric or geometric altitude (TC 11 or 20)
|
||||||
|
*/
|
||||||
|
private parseAltitude(): void {
|
||||||
|
let altitudeType: 'barometric' | 'geometric' = 'barometric';
|
||||||
|
if (this.typeCode === ADSBTypeCode.ALTITUDE_GEOMETRIC) {
|
||||||
|
altitudeType = 'geometric';
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: AltitudePayload = {
|
||||||
|
type: 'altitude',
|
||||||
|
icao: this.icao,
|
||||||
|
altitude: this.extractAltitude(),
|
||||||
|
altitudeType,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.payload = payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse velocity (TC 19)
|
||||||
|
*/
|
||||||
|
private parseVelocity(): void {
|
||||||
|
const payload: VelocityPayload = {
|
||||||
|
type: 'velocity',
|
||||||
|
icao: this.icao,
|
||||||
|
groundSpeed: this.extractGroundSpeed(46),
|
||||||
|
trackAngle: this.extractTrackAngle(52),
|
||||||
|
verticalRate: this.extractVerticalRate(67),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.payload = payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract bits from data array
|
||||||
|
*/
|
||||||
|
private extractBits(startBit: number, length: number): number {
|
||||||
|
let value = 0;
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
const bitIndex = startBit + i;
|
||||||
|
const byteIndex = Math.floor(bitIndex / 8);
|
||||||
|
const bitOffset = 7 - (bitIndex % 8);
|
||||||
|
|
||||||
|
if (byteIndex < this.data.length) {
|
||||||
|
const bit = (this.data[byteIndex] >> bitOffset) & 1;
|
||||||
|
value = (value << 1) | bit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract latitude from compact position
|
||||||
|
*/
|
||||||
|
private extractLatitude(startBit: number): number | undefined {
|
||||||
|
try {
|
||||||
|
const latBits = this.extractBits(startBit, 17);
|
||||||
|
// Simplified: convert 17 bits to latitude (-90 to 90)
|
||||||
|
return ((latBits / 65536) * 180) - 90;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract longitude from compact position
|
||||||
|
*/
|
||||||
|
private extractLongitude(startBit: number): number | undefined {
|
||||||
|
try {
|
||||||
|
const lonBits = this.extractBits(startBit, 18);
|
||||||
|
// Simplified: convert 18 bits to longitude (-180 to 180)
|
||||||
|
return ((lonBits / 131072) * 360) - 180;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract ground speed (knots)
|
||||||
|
*/
|
||||||
|
private extractGroundSpeed(startBit: number): number | undefined {
|
||||||
|
try {
|
||||||
|
const speedBits = this.extractBits(startBit, 10);
|
||||||
|
if (speedBits === 0) return undefined;
|
||||||
|
// Simplified speed calculation
|
||||||
|
return speedBits * 0.1;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract track angle (degrees)
|
||||||
|
*/
|
||||||
|
private extractTrackAngle(startBit: number): number | undefined {
|
||||||
|
try {
|
||||||
|
const angleBits = this.extractBits(startBit, 10);
|
||||||
|
// Convert 10 bits to degrees (0-360)
|
||||||
|
return (angleBits / 1024) * 360;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract altitude (feet)
|
||||||
|
*/
|
||||||
|
private extractAltitude(): number | undefined {
|
||||||
|
try {
|
||||||
|
if (this.data.length < 7) return undefined;
|
||||||
|
|
||||||
|
// Gillham code altitude extraction (D1-D11 bits)
|
||||||
|
const altBits = this.extractBits(40, 11);
|
||||||
|
|
||||||
|
// Simplified altitude calculation (gray code decoding omitted)
|
||||||
|
const altitude = ((altBits >> 1) & 0xff) * 100 + ((altBits & 1) * 100);
|
||||||
|
return altitude > 0 ? altitude : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract vertical rate (feet per minute)
|
||||||
|
*/
|
||||||
|
private extractVerticalRate(startBit: number): number | undefined {
|
||||||
|
try {
|
||||||
|
const vrBits = this.extractBits(startBit, 9);
|
||||||
|
if (vrBits === 0) return undefined;
|
||||||
|
// Simplified vertical rate (100 ft/min per bit)
|
||||||
|
const sign = (vrBits & 0x100) ? -1 : 1;
|
||||||
|
return ((vrBits & 0xff) * 100) * sign;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get aircraft category string
|
||||||
|
*/
|
||||||
|
private getCategoryString(category: number): string {
|
||||||
|
const categories: Record<number, string> = {
|
||||||
|
0: 'No information',
|
||||||
|
1: 'Light',
|
||||||
|
2: 'Small',
|
||||||
|
3: 'Large',
|
||||||
|
4: 'High vortex',
|
||||||
|
5: 'Heavy',
|
||||||
|
6: 'High performance',
|
||||||
|
7: 'Rotorcraft',
|
||||||
|
};
|
||||||
|
return categories[category] || 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export the Frame type for callers that import it from this module
|
||||||
|
export type Frame = IFrame;
|
||||||
@@ -740,7 +740,9 @@ export class Frame implements IFrame {
|
|||||||
result.position.comment = comment;
|
result.position.comment = comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { payload: result };
|
const section = emitSections ? undefined : undefined; // TODO: Build Mic-E sections when emitSections is supported
|
||||||
|
|
||||||
|
return { payload: result, sections: section };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { payload: null };
|
return { payload: null };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { bytesToHex } from '@noble/hashes/utils.js';
|
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
|
||||||
|
|
||||||
import { GroupSecret, Packet } from './meshcore';
|
import { GroupSecret, Packet } from './meshcore';
|
||||||
import type {
|
import type {
|
||||||
@@ -174,6 +174,40 @@ describe('GroupSecret', () => {
|
|||||||
expect(secretFromKey.toHash()).not.toBe(secretFromName.toHash());
|
expect(secretFromKey.toHash()).not.toBe(secretFromName.toHash());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Group Messages examples', () => {
|
||||||
|
it('should match channel hash and decrypt example for #bachelorette', () => {
|
||||||
|
const payloadHex = '5C13C031C6DC206E8B1D8D300C637FCE97204C8763A06B7AC406D39381DC0D07470C019D2047D711D48BAAE988EBBCB0966DC197A7DD99BDF154304B9E3AAA10498686';
|
||||||
|
const payload = hexToBytes(payloadHex);
|
||||||
|
|
||||||
|
const secret = GroupSecret.fromName('#bachelorette');
|
||||||
|
|
||||||
|
// channel hash (first byte) should match
|
||||||
|
expect(payload[0].toString(16).padStart(2, '0')).toBe(secret.toHash());
|
||||||
|
|
||||||
|
const cipherMAC = payload.slice(1, 3);
|
||||||
|
const cipherText = payload.slice(3);
|
||||||
|
|
||||||
|
const decrypted = secret.decrypt(cipherText, cipherMAC);
|
||||||
|
expect(decrypted.message).toBe('A1b2c3: This is a group message in #bachelorette!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match channel hash and decrypt example for PSK channel', () => {
|
||||||
|
const psk = '0378cebadd350b6c2b198f269eda1fd0';
|
||||||
|
const payloadHex = '3E4CA6AAA0C4866CE1ECD8D33C5792C0FCDAFE7C61CF477D86ECA6F5853BE0185A9385570512E585A7546DB5AC522B8531A3E30E165132D75C0CDC397C4094AC8BAFAA9679EF69B40882D7EA37911FE3D8576ECD3FB00B6F398DE92398D9B42548E486';
|
||||||
|
const payload = hexToBytes(payloadHex);
|
||||||
|
|
||||||
|
const secret = new GroupSecret(psk);
|
||||||
|
|
||||||
|
expect(payload[0].toString(16).padStart(2, '0')).toBe(secret.toHash());
|
||||||
|
|
||||||
|
const cipherMAC = payload.slice(1, 3);
|
||||||
|
const cipherText = payload.slice(3);
|
||||||
|
|
||||||
|
const decrypted = secret.decrypt(cipherText, cipherMAC);
|
||||||
|
expect(decrypted.message).toBe('A1b2c3: Hello in this group chat secure by random key, not hashtag key derivation!');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import type {
|
|||||||
BaseSharedSecret,
|
BaseSharedSecret,
|
||||||
ControlPayload,
|
ControlPayload,
|
||||||
DecryptedGroupMessage,
|
DecryptedGroupMessage,
|
||||||
Group,
|
|
||||||
GroupDataPayload,
|
GroupDataPayload,
|
||||||
GroupSecretValue,
|
GroupSecretValue,
|
||||||
GroupTextPayload,
|
GroupTextPayload,
|
||||||
@@ -30,6 +29,7 @@ import type {
|
|||||||
ResponsePayload,
|
ResponsePayload,
|
||||||
TextPayload,
|
TextPayload,
|
||||||
TracePayload,
|
TracePayload,
|
||||||
|
DecryptedTextMessage,
|
||||||
} from '../types/protocol/meshcore.types';
|
} from '../types/protocol/meshcore.types';
|
||||||
|
|
||||||
// Local imports
|
// Local imports
|
||||||
@@ -38,9 +38,12 @@ import {
|
|||||||
AdvertisementFlags,
|
AdvertisementFlags,
|
||||||
BaseGroupSecret,
|
BaseGroupSecret,
|
||||||
BasePacket,
|
BasePacket,
|
||||||
|
payloadNameByValue,
|
||||||
PayloadType,
|
PayloadType,
|
||||||
RouteType,
|
RouteType,
|
||||||
} from '../types/protocol/meshcore.types';
|
} from '../types/protocol/meshcore.types';
|
||||||
|
import type { PacketMeta } from '../types/protocol.types';
|
||||||
|
import { routeTypeNameByValue } from '../pages/meshcore/MeshCoreData';
|
||||||
|
|
||||||
const MAX_PATH_SIZE = 64;
|
const MAX_PATH_SIZE = 64;
|
||||||
|
|
||||||
@@ -56,50 +59,76 @@ export const hasTransportCodes = (routeType: RouteType): boolean => {
|
|||||||
return routeType === RouteType.TRANSPORT_FLOOD || routeType === RouteType.TRANSPORT_DIRECT;
|
return routeType === RouteType.TRANSPORT_FLOOD || routeType === RouteType.TRANSPORT_DIRECT;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Packet extends BasePacket {
|
export class Packet extends BasePacket implements PacketMeta {
|
||||||
private _frameworkSection?: Segment;
|
public routeType: RouteType;
|
||||||
|
public payloadVersion: number;
|
||||||
|
public payloadType: PayloadType;
|
||||||
|
public pathHashSize: number;
|
||||||
|
public pathHashCount: number;
|
||||||
|
public decodedPayload?: Payload;
|
||||||
|
public decrypted?: DecryptedTextMessage | DecryptedGroupMessage;
|
||||||
|
public segments?: Segment[];
|
||||||
|
|
||||||
constructor(data?: Uint8Array | string, emitSections = false) {
|
constructor(
|
||||||
super();
|
header: number,
|
||||||
|
transportCodes: Uint16Array | undefined,
|
||||||
|
pathLength: number,
|
||||||
|
path: Uint8Array,
|
||||||
|
payload: Uint8Array,
|
||||||
|
info: PacketMeta
|
||||||
|
) {
|
||||||
|
super(header, transportCodes, pathLength, path, payload, info);
|
||||||
|
this.routeType = this.getRouteType();
|
||||||
|
this.payloadVersion = this.getPayloadVersion();
|
||||||
|
this.payloadType = this.getPayloadType();
|
||||||
|
this.pathHashSize = this.getPathHashSize();
|
||||||
|
this.pathHashCount = this.getPathHashCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static fromBytes(
|
||||||
|
data: Uint8Array | string,
|
||||||
|
info: PacketMeta,
|
||||||
|
emitSections: boolean = true,
|
||||||
|
): Packet {
|
||||||
if (typeof data !== 'undefined') {
|
if (typeof data !== 'undefined') {
|
||||||
if (typeof data === 'string') {
|
if (typeof data === 'string') {
|
||||||
data = base64ToBytes(data);
|
data = base64ToBytes(data);
|
||||||
}
|
}
|
||||||
this.parse(data, emitSections);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public parse(data: Uint8Array, emitSections = false) {
|
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
const frameworkChildren: Segment[] = [];
|
const segments: Segment[] = [];
|
||||||
|
|
||||||
const header = data[0];
|
const header = data[0];
|
||||||
this.routeType = (header >> 0) & 0x03;
|
const version = (header >> 6) & 0x03;
|
||||||
this.payloadType = (header >> 2) & 0x0F;
|
const routeType = header & 0x03;
|
||||||
this.version = (header >> 6) & 0x03;
|
const payloadType = (header >> 2) & 0x0f;
|
||||||
|
|
||||||
// Build header section
|
// Build header section
|
||||||
if (emitSections) {
|
if (emitSections) {
|
||||||
frameworkChildren.push({
|
segments.push({
|
||||||
name: 'Header',
|
name: 'Header',
|
||||||
offset,
|
offset,
|
||||||
byteCount: 1,
|
byteCount: 1,
|
||||||
attributes: [
|
bitfields: [
|
||||||
{ byteWidth: 1, type: 'uint8', name: 'Header Byte' },
|
{ offset: 6, length: 2, name: 'Payload Version' },
|
||||||
|
{ offset: 2, length: 4, name: `Payload Type (${payloadNameByValue[payloadType]})` },
|
||||||
|
{ offset: 0, length: 2, name: `Route Type (${routeTypeNameByValue[routeType]})` },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
offset++;
|
offset++;
|
||||||
|
|
||||||
let index = 1;
|
let index = 1;
|
||||||
if (hasTransportCodes(this.routeType)) {
|
let transportCodes: Uint16Array | undefined;
|
||||||
|
if (hasTransportCodes(routeType)) {
|
||||||
const view = new DataView(data.buffer, index, index + 4);
|
const view = new DataView(data.buffer, index, index + 4);
|
||||||
this.transportCodes = new Uint16Array(2);
|
transportCodes = new Uint16Array(2);
|
||||||
this.transportCodes[0] = view.getUint16(0, true);
|
transportCodes[0] = view.getUint16(0, true);
|
||||||
this.transportCodes[1] = view.getUint16(2, true);
|
transportCodes[1] = view.getUint16(2, true);
|
||||||
|
|
||||||
if (emitSections) {
|
if (emitSections) {
|
||||||
frameworkChildren.push({
|
segments.push({
|
||||||
name: 'Transport Codes',
|
name: 'Transport Codes',
|
||||||
offset,
|
offset,
|
||||||
byteCount: 4,
|
byteCount: 4,
|
||||||
@@ -113,39 +142,36 @@ export class Packet extends BasePacket {
|
|||||||
offset += 4;
|
offset += 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pathLength = data[index];
|
const pathLength = data[index];
|
||||||
if (emitSections) {
|
|
||||||
frameworkChildren.push({
|
|
||||||
name: 'Path Length',
|
|
||||||
offset,
|
|
||||||
byteCount: 1,
|
|
||||||
attributes: [
|
|
||||||
{ byteWidth: 1, type: 'uint8', name: 'Path Length' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
index++;
|
index++;
|
||||||
offset++;
|
offset++;
|
||||||
|
|
||||||
if (!this.isValidPathLength()) {
|
if (!this.isValidPathLength(pathLength)) {
|
||||||
throw new Error(`MeshCore: invalid path length ${this.pathLength}`)
|
throw new Error(`MeshCore: invalid path length ${pathLength}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathBytesLength = this.getPathBytesLength();
|
const pathHashSize = (pathLength >> 6) + 1;
|
||||||
this.path = new Uint8Array(pathBytesLength);
|
const pathHashCount = pathLength & 0x3f;
|
||||||
|
const pathBytesLength = pathHashSize * pathHashCount;
|
||||||
|
const path = new Uint8Array(pathBytesLength);
|
||||||
for (let i = 0; i < pathBytesLength; i++) {
|
for (let i = 0; i < pathBytesLength; i++) {
|
||||||
this.path[i] = data[index];
|
path[i] = data[index];
|
||||||
index++;
|
index++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (emitSections) {
|
if (emitSections) {
|
||||||
frameworkChildren.push({
|
segments.push({
|
||||||
name: 'Path',
|
name: 'Path',
|
||||||
offset,
|
offset: offset - 1,
|
||||||
byteCount: pathBytesLength,
|
byteCount: pathBytesLength,
|
||||||
attributes: [
|
attributes: [
|
||||||
|
{ byteWidth: 1, type: 'uint8', name: 'Path Length' },
|
||||||
{ byteWidth: pathBytesLength, type: 'uint8', name: 'Path Bytes' },
|
{ byteWidth: pathBytesLength, type: 'uint8', name: 'Path Bytes' },
|
||||||
],
|
],
|
||||||
|
bitfields: [
|
||||||
|
{ offset: 6, length: 2, name: `Hash Size Selector (size=${pathHashSize})` },
|
||||||
|
{ offset: 0, length: 6, name: `Hash Count (count=${pathHashCount})` },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
offset += pathBytesLength;
|
offset += pathBytesLength;
|
||||||
@@ -154,123 +180,117 @@ export class Packet extends BasePacket {
|
|||||||
throw new Error('MeshCore: invalid packet: no payload');
|
throw new Error('MeshCore: invalid packet: no payload');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (emitSections) {
|
|
||||||
this._frameworkSection = {
|
|
||||||
name: 'Framework',
|
|
||||||
offset: 0,
|
|
||||||
byteCount: offset,
|
|
||||||
children: frameworkChildren,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const payloadBytesLength = data.length - index;
|
const payloadBytesLength = data.length - index;
|
||||||
this.payload = new Uint8Array(payloadBytesLength);
|
const payload = new Uint8Array(payloadBytesLength);
|
||||||
for (let i = 0; i < payloadBytesLength; i++) {
|
for (let i = 0; i < payloadBytesLength; i++) {
|
||||||
this.payload[i] = data[index];
|
payload[i] = data[index];
|
||||||
index++;
|
index++;
|
||||||
}
|
}
|
||||||
|
if (emitSections) {
|
||||||
|
segments.push({
|
||||||
|
name: 'Payload',
|
||||||
|
offset,
|
||||||
|
byteCount: payloadBytesLength,
|
||||||
|
attributes: [
|
||||||
|
{ byteWidth: payloadBytesLength, type: 'uint8', name: 'Payload Bytes' },
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public parseBase64(data: string) {
|
const packet = new Packet(header, transportCodes, pathLength, path, payload, info);
|
||||||
return this.parse(base64ToBytes(data));
|
if (emitSections) {
|
||||||
|
packet.segments = segments;
|
||||||
|
packet.decodedPayload = packet.decode(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getFrameworkSection(): Segment | undefined {
|
return packet;
|
||||||
return this._frameworkSection;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private isValidPathLength(): boolean {
|
public static isValidPathLength(pathLength: number): boolean {
|
||||||
const hashCount = this.getPathHashCount();
|
const hashCount = pathLength & 0x3F;
|
||||||
const hashSize = this.getPathHashSize();
|
const hashSize = (pathLength >> 6) + 1;
|
||||||
if (hashSize === 4) return false; // reserved
|
if (hashSize === 4) return false; // reserved
|
||||||
return hashCount * hashSize <= MAX_PATH_SIZE;
|
return hashCount * hashSize <= MAX_PATH_SIZE;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getPathHashSize(): number {
|
public hash(): string {
|
||||||
return (this.pathLength >> 6) + 1;
|
const payloadType = this.getPayloadType();
|
||||||
}
|
let data = new Uint8Array([payloadType]);
|
||||||
|
if (payloadType === PayloadType.TRACE) {
|
||||||
public getPathHashCount(): number {
|
|
||||||
return this.pathLength & 63;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getPathBytesLength(): number {
|
|
||||||
return this.getPathHashCount() * this.getPathHashSize();
|
|
||||||
}
|
|
||||||
|
|
||||||
public hash(): Uint8Array {
|
|
||||||
let data = new Uint8Array([this.payloadType]);
|
|
||||||
if (this.payloadType === PayloadType.TRACE) {
|
|
||||||
data = new Uint8Array([...data, ...this.path]);
|
data = new Uint8Array([...data, ...this.path]);
|
||||||
}
|
}
|
||||||
data = new Uint8Array([...data, ...this.payload]);
|
data = new Uint8Array([...data, ...this.payload]);
|
||||||
const hash = sha256.create().update(data).digest();
|
const hash = sha256.create().update(data).digest();
|
||||||
return hash.slice(0, 8);
|
return bytesToHex(hash.slice(0, 8));
|
||||||
}
|
}
|
||||||
|
|
||||||
public decode(): Payload;
|
public toBytes(): Uint8Array {
|
||||||
public decode(emitSections: true): { payload: Payload; sections?: Segment[] };
|
const headerBytes = new Uint8Array([this.header]);
|
||||||
public decode(emitSections = false): Payload | { payload: Payload; sections?: Segment[] } {
|
const transportBytes = this.transportCodes ? new Uint8Array(this.transportCodes.buffer) : new Uint8Array();
|
||||||
let decodedPayload: Payload;
|
const pathLengthByte = new Uint8Array([this.pathLength]);
|
||||||
let payloadSections: Segment[] | undefined;
|
const pathBytes = this.path;
|
||||||
|
const payloadBytes = this.payload;
|
||||||
|
return new Uint8Array([...headerBytes, ...transportBytes, ...pathLengthByte, ...pathBytes, ...payloadBytes]);
|
||||||
|
}
|
||||||
|
|
||||||
switch (this.payloadType) {
|
public decode(emitSections: boolean = false): Payload {
|
||||||
|
if (typeof this.decodedPayload !== 'undefined' && this.decodedPayload !== null && this.decodedPayload.payloadType === this.getPayloadType()) {
|
||||||
|
return this.decodedPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: Payload;
|
||||||
|
let sections: Segment[] | undefined;
|
||||||
|
const payloadType = this.getPayloadType();
|
||||||
|
|
||||||
|
switch (payloadType) {
|
||||||
case PayloadType.REQUEST:
|
case PayloadType.REQUEST:
|
||||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeRequest(emitSections));
|
({ payload, sections } = this.decodeRequest(emitSections));
|
||||||
break;
|
break;
|
||||||
case PayloadType.RESPONSE:
|
case PayloadType.RESPONSE:
|
||||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeResponse(emitSections));
|
({ payload, sections } = this.decodeResponse(emitSections));
|
||||||
break;
|
break;
|
||||||
case PayloadType.TEXT:
|
case PayloadType.TEXT:
|
||||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeText(emitSections));
|
({ payload, sections } = this.decodeText(emitSections));
|
||||||
break;
|
break;
|
||||||
case PayloadType.ACK:
|
case PayloadType.ACK:
|
||||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeAck(emitSections));
|
({ payload, sections } = this.decodeAck(emitSections));
|
||||||
break;
|
break;
|
||||||
case PayloadType.ADVERT:
|
case PayloadType.ADVERT:
|
||||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeAdvert(emitSections));
|
({ payload, sections } = this.decodeAdvert(emitSections));
|
||||||
break;
|
break;
|
||||||
case PayloadType.GROUP_TEXT:
|
case PayloadType.GROUP_TEXT:
|
||||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeGroupText(emitSections));
|
({ payload, sections } = this.decodeGroupText(emitSections));
|
||||||
break;
|
break;
|
||||||
case PayloadType.GROUP_DATA:
|
case PayloadType.GROUP_DATA:
|
||||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeGroupData(emitSections));
|
({ payload, sections } = this.decodeGroupData(emitSections));
|
||||||
break;
|
break;
|
||||||
case PayloadType.ANON_REQ:
|
case PayloadType.ANON_REQ:
|
||||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeAnonReq(emitSections));
|
({ payload, sections } = this.decodeAnonReq(emitSections));
|
||||||
break;
|
break;
|
||||||
case PayloadType.PATH:
|
case PayloadType.PATH:
|
||||||
({ payload: decodedPayload, sections: payloadSections } = this.decodePath(emitSections));
|
({ payload, sections } = this.decodePath(emitSections));
|
||||||
break;
|
break;
|
||||||
case PayloadType.TRACE:
|
case PayloadType.TRACE:
|
||||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeTrace(emitSections));
|
({ payload, sections } = this.decodeTrace(emitSections));
|
||||||
break;
|
break;
|
||||||
case PayloadType.MULTIPART:
|
case PayloadType.MULTIPART:
|
||||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeMultipart(emitSections));
|
({ payload, sections } = this.decodeMultipart(emitSections));
|
||||||
break;
|
break;
|
||||||
case PayloadType.CONTROL:
|
case PayloadType.CONTROL:
|
||||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeControl(emitSections));
|
({ payload, sections } = this.decodeControl(emitSections));
|
||||||
break;
|
break;
|
||||||
case PayloadType.RAW_CUSTOM:
|
case PayloadType.RAW_CUSTOM:
|
||||||
({ payload: decodedPayload, sections: payloadSections } = this.decodeRawCustom(emitSections));
|
({ payload, sections } = this.decodeRawCustom(emitSections));
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`MeshCore: can't decode payload ${this.payloadType}`)
|
throw new Error(`MeshCore: can't decode payload ${payloadType}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!emitSections) {
|
if (emitSections) {
|
||||||
return decodedPayload;
|
this.segments = this.segments ? [...this.segments, ...(sections ?? [])] : sections;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sections: Segment[] = [];
|
return payload;
|
||||||
if (this._frameworkSection) {
|
|
||||||
sections.push(this._frameworkSection);
|
|
||||||
}
|
|
||||||
if (payloadSections) {
|
|
||||||
sections.push(...payloadSections);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { payload: decodedPayload, sections: sections.length > 0 ? sections : undefined };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private decodeEncrypted<T extends { sections?: Segment[] }>(kind: PayloadType, emitSections: boolean): { payload: T; sections?: Segment[] } {
|
private decodeEncrypted<T extends { sections?: Segment[] }>(kind: PayloadType, emitSections: boolean): { payload: T; sections?: Segment[] } {
|
||||||
@@ -322,7 +342,30 @@ export class Packet extends BasePacket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private decodeAck(emitSections = false): { payload: AckPayload; sections?: Segment[] } {
|
private decodeAck(emitSections = false): { payload: AckPayload; sections?: Segment[] } {
|
||||||
return this.decodeEncrypted<AckPayload>(PayloadType.ACK, emitSections);
|
let offset = 0;
|
||||||
|
const sections: Segment[] = [];
|
||||||
|
const buffer = new BufferReader(this.payload);
|
||||||
|
|
||||||
|
const checksum = buffer.readBytes(4);
|
||||||
|
|
||||||
|
if (emitSections) {
|
||||||
|
sections.push({
|
||||||
|
name: 'Ack Payload',
|
||||||
|
offset,
|
||||||
|
byteCount: this.payload.length,
|
||||||
|
attributes: [
|
||||||
|
{ byteWidth: 4, type: 'uint8', name: 'Checksum' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
payload: {
|
||||||
|
payloadType: PayloadType.ACK,
|
||||||
|
checksum,
|
||||||
|
} as AckPayload,
|
||||||
|
sections: emitSections ? sections : undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private decodeAdvert(emitSections = false): { payload: AdvertPayload; sections?: Segment[] } {
|
private decodeAdvert(emitSections = false): { payload: AdvertPayload; sections?: Segment[] } {
|
||||||
@@ -343,7 +386,14 @@ export class Packet extends BasePacket {
|
|||||||
const flags = buffer.readUint8();
|
const flags = buffer.readUint8();
|
||||||
attributes.push({ byteWidth: 1, type: 'uint8', name: 'flags' });
|
attributes.push({ byteWidth: 1, type: 'uint8', name: 'flags' });
|
||||||
|
|
||||||
const appdata: { flags: number; latitude?: number; longitude?: number; feature1?: number; feature2?: number; name?: string } = {
|
const appdata: {
|
||||||
|
flags: number;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
feature1?: number;
|
||||||
|
feature2?: number;
|
||||||
|
name?: string
|
||||||
|
} = {
|
||||||
flags,
|
flags,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -374,7 +424,7 @@ export class Packet extends BasePacket {
|
|||||||
|
|
||||||
if (emitSections) {
|
if (emitSections) {
|
||||||
sections.push({
|
sections.push({
|
||||||
name: 'Payload',
|
name: 'Advert Payload',
|
||||||
offset,
|
offset,
|
||||||
byteCount: this.payload.length,
|
byteCount: this.payload.length,
|
||||||
attributes,
|
attributes,
|
||||||
@@ -385,7 +435,7 @@ export class Packet extends BasePacket {
|
|||||||
payload: {
|
payload: {
|
||||||
payloadType: PayloadType.ADVERT,
|
payloadType: PayloadType.ADVERT,
|
||||||
publicKey,
|
publicKey,
|
||||||
timestamp: timestampValue === 0 ? undefined : new Date(timestampValue),
|
timestamp: timestampValue === 0 ? undefined : new Date(timestampValue * 1000),
|
||||||
signature,
|
signature,
|
||||||
appdata,
|
appdata,
|
||||||
},
|
},
|
||||||
@@ -476,7 +526,10 @@ export class Packet extends BasePacket {
|
|||||||
private decodeTrace(emitSections = false): { payload: TracePayload; sections?: Segment[] } {
|
private decodeTrace(emitSections = false): { payload: TracePayload; sections?: Segment[] } {
|
||||||
const sections: Segment[] = [];
|
const sections: Segment[] = [];
|
||||||
const buffer = new BufferReader(this.payload);
|
const buffer = new BufferReader(this.payload);
|
||||||
const data = buffer.readBytes();
|
|
||||||
|
const tag = buffer.readUint32LE();
|
||||||
|
const authCode = buffer.readBytes(4);
|
||||||
|
const nodes = buffer.readBytes();
|
||||||
|
|
||||||
if (emitSections) {
|
if (emitSections) {
|
||||||
sections.push({
|
sections.push({
|
||||||
@@ -484,7 +537,9 @@ export class Packet extends BasePacket {
|
|||||||
offset: 0,
|
offset: 0,
|
||||||
byteCount: this.payload.length,
|
byteCount: this.payload.length,
|
||||||
attributes: [
|
attributes: [
|
||||||
{ byteWidth: data.length, type: 'uint8', name: 'data' },
|
{ byteWidth: 4, type: 'uint32le', name: 'Tag' },
|
||||||
|
{ byteWidth: 4, type: 'uint8', name: 'Auth Code' },
|
||||||
|
{ byteWidth: nodes.length, type: 'uint8', name: 'Nodes' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -492,7 +547,9 @@ export class Packet extends BasePacket {
|
|||||||
return {
|
return {
|
||||||
payload: {
|
payload: {
|
||||||
payloadType: PayloadType.TRACE,
|
payloadType: PayloadType.TRACE,
|
||||||
data,
|
tag,
|
||||||
|
authCode,
|
||||||
|
nodes,
|
||||||
},
|
},
|
||||||
sections: emitSections ? sections : undefined,
|
sections: emitSections ? sections : undefined,
|
||||||
};
|
};
|
||||||
@@ -679,7 +736,7 @@ export class SharedSecret implements BaseSharedSecret {
|
|||||||
throw new Error(`invalid MAC`)
|
throw new Error(`invalid MAC`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const block = ecb(this.bytes);
|
const block = ecb(this.bytes.slice(0, 16), { disablePadding: true });
|
||||||
const plain = block.decrypt(cipherText);
|
const plain = block.decrypt(cipherText);
|
||||||
|
|
||||||
return plain;
|
return plain;
|
||||||
@@ -711,6 +768,36 @@ export class StaticSecret {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class Group {
|
||||||
|
public name: string;
|
||||||
|
private secret: BaseGroupSecret;
|
||||||
|
protected isPublic: boolean;
|
||||||
|
|
||||||
|
constructor(name: string, secret?: string | Uint8Array | GroupSecret, isPublic: boolean = false) {
|
||||||
|
this.name = name;
|
||||||
|
this.isPublic = isPublic;
|
||||||
|
if (secret instanceof GroupSecret) {
|
||||||
|
this.secret = secret;
|
||||||
|
} else if (secret) {
|
||||||
|
this.secret = new GroupSecret(secret);
|
||||||
|
} else {
|
||||||
|
this.secret = GroupSecret.fromName(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public toHash(): string {
|
||||||
|
return this.secret.toHash();
|
||||||
|
}
|
||||||
|
|
||||||
|
public toString(): string {
|
||||||
|
return this.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public decrypt(cipherText: Uint8Array, cipherMAC: Uint8Array): DecryptedGroupMessage {
|
||||||
|
return this.secret.decrypt(cipherText, cipherMAC);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class GroupSecret implements BaseGroupSecret {
|
export class GroupSecret implements BaseGroupSecret {
|
||||||
private bytes: Uint8Array;
|
private bytes: Uint8Array;
|
||||||
|
|
||||||
@@ -741,23 +828,24 @@ export class GroupSecret implements BaseGroupSecret {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toHash(): string {
|
toHash(): string {
|
||||||
return this.bytes[0].toString(16).padStart(2, '0')
|
const h = sha256.create().update(this.bytes.slice(0, 16)).digest();
|
||||||
|
return h[0].toString(16).padStart(2, '0')
|
||||||
}
|
}
|
||||||
|
|
||||||
decrypt(cipherText: Uint8Array, cipherMAC: Uint8Array): DecryptedGroupMessage {
|
decrypt(cipherText: Uint8Array, cipherMAC: Uint8Array): DecryptedGroupMessage {
|
||||||
const ourMAC = hmac(sha256, this.bytes, cipherText)
|
const ourMAC = hmac(sha256, this.bytes, cipherText).slice(0, 2)
|
||||||
if (!constantTimeEqual(cipherMAC, ourMAC)) {
|
if (!constantTimeEqual(cipherMAC, ourMAC)) {
|
||||||
throw new Error('invalid MAC');
|
throw new Error('invalid MAC');
|
||||||
}
|
}
|
||||||
|
|
||||||
const block = ecb(this.bytes);
|
const block = ecb(this.bytes.slice(0, 16), { disablePadding: true });
|
||||||
const plain = block.decrypt(cipherText);
|
const plain = block.decrypt(cipherText);
|
||||||
if (plain.length < 5) {
|
if (plain.length < 5) {
|
||||||
throw new Error('invalid payload');
|
throw new Error('invalid payload');
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = new BufferReader(plain);
|
const reader = new BufferReader(plain);
|
||||||
const timestamp = new Date(reader.readUint32LE());
|
const timestamp = new Date(reader.readUint32LE() * 1000);
|
||||||
const flags = reader.readUint8();
|
const flags = reader.readUint8();
|
||||||
let message = new TextDecoder('utf-8').decode(reader.readBytes());
|
let message = new TextDecoder('utf-8').decode(reader.readBytes());
|
||||||
const nullPos = message.indexOf('\0')
|
const nullPos = message.indexOf('\0')
|
||||||
@@ -774,7 +862,7 @@ export class GroupSecret implements BaseGroupSecret {
|
|||||||
|
|
||||||
static fromName(name: string): GroupSecret {
|
static fromName(name: string): GroupSecret {
|
||||||
const hash = sha256.create().update(new TextEncoder().encode(name)).digest();
|
const hash = sha256.create().update(new TextEncoder().encode(name)).digest();
|
||||||
return new GroupSecret(hash.slice(0, 32));
|
return new GroupSecret(hash.slice(0, 16));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,17 +878,11 @@ export class KeyManager {
|
|||||||
public addGroup(name: string, secret?: string | Uint8Array) {
|
public addGroup(name: string, secret?: string | Uint8Array) {
|
||||||
let group: Group;
|
let group: Group;
|
||||||
if (secret) {
|
if (secret) {
|
||||||
group = {
|
group = new Group(name, new GroupSecret(secret));
|
||||||
name: name,
|
|
||||||
secret: new GroupSecret(secret)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
group = {
|
group = new Group(name);
|
||||||
name: name,
|
|
||||||
secret: GroupSecret.fromName(name)
|
|
||||||
}
|
}
|
||||||
}
|
const hash = group.toHash();
|
||||||
const hash = group.secret.toHash();
|
|
||||||
this.groups.set(hash, [...this.groups.get(hash) || [], group]);
|
this.groups.set(hash, [...this.groups.get(hash) || [], group]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -813,7 +895,7 @@ export class KeyManager {
|
|||||||
for (const group of this.groups.get(channelHash) || []) {
|
for (const group of this.groups.get(channelHash) || []) {
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
...group.secret.decrypt(cipherText, cipherMAC),
|
...group.decrypt(cipherText, cipherMAC),
|
||||||
group: group.name
|
group: group.name
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
37
ui/src/services/ADSBService.ts
Normal file
37
ui/src/services/ADSBService.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { APIService } from './API';
|
||||||
|
import type { Packet } from '../types/protocol.types';
|
||||||
|
|
||||||
|
export interface FetchedADSBPacket extends Packet {
|
||||||
|
id?: number;
|
||||||
|
radio_id?: number;
|
||||||
|
radio?: {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
icao?: string;
|
||||||
|
callsign?: string;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
altitude?: number;
|
||||||
|
ground_speed?: number;
|
||||||
|
track_angle?: number;
|
||||||
|
vertical_rate?: number;
|
||||||
|
raw?: string;
|
||||||
|
received_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ADSBServiceImpl {
|
||||||
|
private api: APIService;
|
||||||
|
|
||||||
|
constructor(api: APIService) {
|
||||||
|
this.api = api;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async fetchPackets(limit = 200): Promise<FetchedADSBPacket[]> {
|
||||||
|
const endpoint = '/adsb/packets';
|
||||||
|
const params = { limit };
|
||||||
|
return this.api.fetch<FetchedADSBPacket[]>(endpoint, { params });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ADSBServiceImpl;
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import axios, { type AxiosRequestConfig } from 'axios';
|
import axios, { type AxiosRequestConfig } from 'axios';
|
||||||
import type { Radio } from '../types/radio.types';
|
import type { Radio } from '../types/radio.types';
|
||||||
|
|
||||||
|
export interface Pager<T> {
|
||||||
|
items: T[]; // Array of items for the current page
|
||||||
|
total: number; // Total number of items across all pages
|
||||||
|
page: number; // Current page number
|
||||||
|
limit: number; // Number of items per page
|
||||||
|
}
|
||||||
|
|
||||||
export class APIService {
|
export class APIService {
|
||||||
private readonly client;
|
private readonly client;
|
||||||
|
|
||||||
@@ -18,6 +25,17 @@ export class APIService {
|
|||||||
return response.data as T;
|
return response.data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async fetchPaginated<T>(endpoint: string, page: number = 1, limit: number = 20, extraParams?: Record<string, unknown>): Promise<Pager<T>> {
|
||||||
|
const params: Record<string, unknown> = { page, limit };
|
||||||
|
if (extraParams) {
|
||||||
|
Object.assign(params, extraParams);
|
||||||
|
}
|
||||||
|
const response = await this.client.get<Pager<T>>(endpoint, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return response.data as Pager<T>;
|
||||||
|
}
|
||||||
|
|
||||||
public async fetchRadios(protocol?: string): Promise<Radio[]> {
|
public async fetchRadios(protocol?: string): Promise<Radio[]> {
|
||||||
const endpoint = protocol ? `/radios/${encodeURIComponent(protocol)}` : '/radios';
|
const endpoint = protocol ? `/radios/${encodeURIComponent(protocol)}` : '/radios';
|
||||||
return this.fetch<Radio[]>(endpoint);
|
return this.fetch<Radio[]>(endpoint);
|
||||||
|
|||||||
@@ -1,22 +1,16 @@
|
|||||||
import type { APIService } from './API';
|
import type { APIService, Pager } from './API';
|
||||||
import type { Group } from '../types/protocol/meshcore.types';
|
import type { GroupTextPayload, Payload } from '../types/protocol/meshcore.types';
|
||||||
import type { Packet } from '../types/protocol.types';
|
import { Packet, Group } from '../protocols/meshcore';
|
||||||
|
import { base64ToBytes } from '../util';
|
||||||
|
import type { KeyManager } from '../protocols/meshcore';
|
||||||
|
// Removed duplicate type import `Packet` from types to avoid identifier clash
|
||||||
import { PayloadType } from '../types/protocol/meshcore.types';
|
import { PayloadType } from '../types/protocol/meshcore.types';
|
||||||
import { GroupSecret } from '../protocols/meshcore';
|
import { GroupSecret } from '../protocols/meshcore';
|
||||||
|
import { PacketInfo } from '../types/protocol.types';
|
||||||
|
|
||||||
interface FetchedMeshCoreGroup {
|
// OLD
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
secret: string;
|
|
||||||
isPublic: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MeshCoreGroupRecord = Group & {
|
export interface FetchedMeshCorePacket {
|
||||||
id: number;
|
|
||||||
isPublic: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface FetchedMeshCorePacket extends Packet {
|
|
||||||
id: number;
|
id: number;
|
||||||
radio_id: number;
|
radio_id: number;
|
||||||
version: number;
|
version: number;
|
||||||
@@ -28,9 +22,85 @@ export interface FetchedMeshCorePacket extends Packet {
|
|||||||
raw: string;
|
raw: string;
|
||||||
channel_hash: string;
|
channel_hash: string;
|
||||||
received_at: string;
|
received_at: string;
|
||||||
|
// Optional decrypted group payload (if provided by caller via KeyManager)
|
||||||
|
decrypted?: {
|
||||||
|
timestamp: string;
|
||||||
|
flags?: number;
|
||||||
|
message: string;
|
||||||
|
group?: string;
|
||||||
|
};
|
||||||
|
decodedPayload?: Payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MeshCoreServiceImpl {
|
export interface MeshCoreOriginSignalSeriesData {
|
||||||
|
snr: number[];
|
||||||
|
rssi: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeshCoreOriginStats {
|
||||||
|
timestamps: number[];
|
||||||
|
signal: Record<string, MeshCoreOriginSignalSeriesData>;
|
||||||
|
counts: Record<string, number[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeshCorePacketStats {
|
||||||
|
timestamps: number[];
|
||||||
|
routing: {
|
||||||
|
flood: number[];
|
||||||
|
direct: number[];
|
||||||
|
transport: number[];
|
||||||
|
};
|
||||||
|
payload: Record<string, number[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW
|
||||||
|
|
||||||
|
interface FetchedGroup {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
secret: string;
|
||||||
|
isPublic: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FetchedNode {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: number;
|
||||||
|
prefix: string;
|
||||||
|
public_key: string;
|
||||||
|
first_heard_at: string;
|
||||||
|
last_heard_at: string;
|
||||||
|
last_latitude?: number;
|
||||||
|
last_longitude?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FetchedNodeDistance extends FetchedNode {
|
||||||
|
distance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FetchedNodesCloseTo {
|
||||||
|
node: FetchedNode;
|
||||||
|
nodes: FetchedNodeDistance[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FetchedPacket {
|
||||||
|
id: number;
|
||||||
|
radio_id: number;
|
||||||
|
snr: number;
|
||||||
|
rssi: number;
|
||||||
|
version: number;
|
||||||
|
route_type: number;
|
||||||
|
payload_type: number;
|
||||||
|
hash: string;
|
||||||
|
path: string;
|
||||||
|
payload: string;
|
||||||
|
raw: string;
|
||||||
|
parsed: any;
|
||||||
|
channel_hash: string;
|
||||||
|
received_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MeshCoreService {
|
||||||
private api: APIService;
|
private api: APIService;
|
||||||
|
|
||||||
constructor(api: APIService) {
|
constructor(api: APIService) {
|
||||||
@@ -41,16 +111,9 @@ export class MeshCoreServiceImpl {
|
|||||||
* Fetch all available MeshCore groups
|
* Fetch all available MeshCore groups
|
||||||
* @returns Array of Group objects with metadata
|
* @returns Array of Group objects with metadata
|
||||||
*/
|
*/
|
||||||
public async fetchGroups(): Promise<MeshCoreGroupRecord[]> {
|
public async fetchGroups(): Promise<Group[]> {
|
||||||
const groups = await this.api.fetch<FetchedMeshCoreGroup[]>('/meshcore/groups');
|
const groups = await this.api.fetch<FetchedGroup[]>('/meshcore/groups');
|
||||||
return groups.map((group) => ({
|
return groups.map((group) => new Group(group.name, group.secret, group.isPublic));
|
||||||
id: group.id,
|
|
||||||
name: group.name,
|
|
||||||
secret: group.secret && group.secret.trim().length > 0
|
|
||||||
? new GroupSecret(group.secret)
|
|
||||||
: GroupSecret.fromName(group.name),
|
|
||||||
isPublic: group.isPublic,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,7 +127,8 @@ export class MeshCoreServiceImpl {
|
|||||||
limit = 200,
|
limit = 200,
|
||||||
type?: number,
|
type?: number,
|
||||||
channelHash?: string,
|
channelHash?: string,
|
||||||
): Promise<FetchedMeshCorePacket[]> {
|
keyManager?: KeyManager,
|
||||||
|
): Promise<Packet[]> {
|
||||||
const endpoint = '/meshcore/packets';
|
const endpoint = '/meshcore/packets';
|
||||||
const params: Record<string, unknown> = { limit };
|
const params: Record<string, unknown> = { limit };
|
||||||
if (type !== undefined) {
|
if (type !== undefined) {
|
||||||
@@ -73,7 +137,46 @@ export class MeshCoreServiceImpl {
|
|||||||
if (channelHash !== undefined) {
|
if (channelHash !== undefined) {
|
||||||
params.channel_hash = channelHash;
|
params.channel_hash = channelHash;
|
||||||
}
|
}
|
||||||
return this.api.fetch<FetchedMeshCorePacket[]>(endpoint, { params });
|
|
||||||
|
const fetchedPackets = await this.api.fetch<FetchedPacket[]>(endpoint, { params });
|
||||||
|
const packets = fetchedPackets.map(p => {
|
||||||
|
// console.debug(`Fetched packet ${p.hash} with payload type ${p.payload_type} and route type ${p.route_type}`, { raw: p.raw, parsed: p.parsed });
|
||||||
|
return Packet.fromBytes(base64ToBytes(p.raw), new PacketInfo({
|
||||||
|
receivedAt: new Date(p.received_at),
|
||||||
|
snr: p.snr,
|
||||||
|
rssi: p.rssi,
|
||||||
|
radioName: undefined, // Assuming radioName is not provided in the API response
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!keyManager) return packets;
|
||||||
|
|
||||||
|
// Attempt to decrypt any group payloads using provided KeyManager
|
||||||
|
for (const packet of packets) {
|
||||||
|
try {
|
||||||
|
const payload = packet.decode(true);
|
||||||
|
packet.decodedPayload = payload;
|
||||||
|
// console.log('Decoded packet payload', { payload, payloadType: packet.payloadType });
|
||||||
|
try {
|
||||||
|
switch (packet.payloadType) {
|
||||||
|
case PayloadType.GROUP_TEXT:
|
||||||
|
const groupText = payload as GroupTextPayload;
|
||||||
|
packet.decrypted = keyManager.decryptGroup(groupText.channelHash, groupText.cipherText, groupText.cipherMAC);
|
||||||
|
break;
|
||||||
|
case PayloadType.GROUP_DATA:
|
||||||
|
const groupData = payload as GroupTextPayload; // Assuming GROUP_DATA has same structure as GROUP_TEXT for decryption
|
||||||
|
packet.decrypted = keyManager.decryptGroup(groupData.channelHash, groupData.cipherText, groupData.cipherMAC);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore decryption failures
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to decode packet payload', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return packets;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,9 +184,46 @@ export class MeshCoreServiceImpl {
|
|||||||
* @param channelHash The channel hash to fetch packets for
|
* @param channelHash The channel hash to fetch packets for
|
||||||
* @returns Array of raw packet data
|
* @returns Array of raw packet data
|
||||||
*/
|
*/
|
||||||
public async fetchGroupPackets(channelHash: string): Promise<FetchedMeshCorePacket[]> {
|
public async fetchGroupPackets(channelHash: string, keyManager?: KeyManager): Promise<Packet[]> {
|
||||||
return this.fetchPackets(200, PayloadType.GROUP_TEXT, channelHash);
|
const packets = await this.fetchPackets(200, PayloadType.GROUP_TEXT, channelHash);
|
||||||
|
|
||||||
|
if (!keyManager) return packets;
|
||||||
|
|
||||||
|
for (const packet of packets) {
|
||||||
|
const payload = packet.decodedPayload;
|
||||||
|
if (payload && 'cipherText' in payload && 'cipherMAC' in payload && 'channelHash' in payload) {
|
||||||
|
try {
|
||||||
|
const dec = keyManager.decryptGroup(
|
||||||
|
(payload as any).channelHash,
|
||||||
|
(payload as any).cipherText,
|
||||||
|
(payload as any).cipherMAC
|
||||||
|
);
|
||||||
|
packet.decrypted = dec;
|
||||||
|
} catch (e) {
|
||||||
|
// ignore decryption failures
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MeshCoreServiceImpl;
|
return packets;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async fetchOriginStats(view: 'daily' | 'weekly' | 'monthly'): Promise<MeshCoreOriginStats> {
|
||||||
|
return this.api.fetch<MeshCoreOriginStats>(`/meshcore/stats/origins/${encodeURIComponent(view)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async fetchPacketStats(view: 'daily' | 'weekly' | 'monthly'): Promise<MeshCorePacketStats> {
|
||||||
|
return this.api.fetch<MeshCorePacketStats>(`/meshcore/stats/packets/${encodeURIComponent(view)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async fetchNodes(page: number = 1, limit: number = 200, type?: string): Promise<Pager<FetchedNode>> {
|
||||||
|
const extra: Record<string, unknown> | undefined = type ? { type } : undefined;
|
||||||
|
return this.api.fetchPaginated<FetchedNode>('/meshcore/nodes', page, limit, extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async fetchNodesCloseTo(hash: string): Promise<FetchedNodesCloseTo> {
|
||||||
|
return this.api.fetch<FetchedNodesCloseTo>(`/meshcore/nodes/close-to/${encodeURIComponent(hash)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MeshCoreService;
|
||||||
|
|||||||
@@ -1,55 +1,29 @@
|
|||||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
|
import { Packet, KeyManager } from '../protocols/meshcore';
|
||||||
import { Packet as MeshCorePacket } from '../protocols/meshcore';
|
import { PacketInfo } from '../types/protocol.types';
|
||||||
import type { Payload } from '../types/protocol/meshcore.types';
|
|
||||||
import type { Packet } from '../types/protocol.types';
|
|
||||||
import { BaseStream } from './Stream';
|
import { BaseStream } from './Stream';
|
||||||
|
import { base64ToBytes } from '../util';
|
||||||
|
|
||||||
export interface MeshCoreMessage extends Packet {
|
interface StreamPacket {
|
||||||
topic: string;
|
time: string;
|
||||||
raw: Uint8Array;
|
raw: string;
|
||||||
hash: string;
|
|
||||||
decodedPayload?: Payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MeshCoreJsonEnvelope {
|
|
||||||
payloadBase64?: string;
|
|
||||||
payloadHex?: string;
|
|
||||||
snr?: number;
|
snr?: number;
|
||||||
rssi?: number;
|
rssi?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MeshCoreStream extends BaseStream {
|
export class MeshCoreStream extends BaseStream {
|
||||||
|
public keyManager: KeyManager = new KeyManager();
|
||||||
constructor(autoConnect = false) {
|
constructor(autoConnect = false) {
|
||||||
super({}, autoConnect);
|
super({}, autoConnect);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected decodeMessage(topic: string, payload: Uint8Array): MeshCoreMessage {
|
protected decodeMessage(topic: string, payload: Uint8Array): Packet {
|
||||||
const { bytes: packetBytes, snr, rssi } = this.extractPacketBytes(payload);
|
const { bytes: packetBytes, time, snr, rssi } = this.extractPacketBytes(payload);
|
||||||
const parsed = new MeshCorePacket();
|
return Packet.fromBytes(packetBytes, new PacketInfo({
|
||||||
parsed.parse(packetBytes);
|
receivedAt: time || new Date(),
|
||||||
|
snr: snr,
|
||||||
let decodedPayload: Payload | undefined;
|
rssi: rssi,
|
||||||
try {
|
radioName: this.extractRadioNameFromTopic(topic),
|
||||||
decodedPayload = parsed.decode();
|
}));
|
||||||
} catch {
|
|
||||||
decodedPayload = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('parsed packet', parsed, { decodedPayload });
|
|
||||||
|
|
||||||
// Extract radio name from topic: meshcore/packet/<base64-encoded-radio-name>
|
|
||||||
const radioName = this.extractRadioNameFromTopic(topic);
|
|
||||||
|
|
||||||
return {
|
|
||||||
topic,
|
|
||||||
receivedAt: new Date(),
|
|
||||||
raw: packetBytes,
|
|
||||||
hash: bytesToHex(parsed.hash()),
|
|
||||||
decodedPayload,
|
|
||||||
radioName,
|
|
||||||
snr,
|
|
||||||
rssi,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractRadioNameFromTopic(topic: string): string | undefined {
|
private extractRadioNameFromTopic(topic: string): string | undefined {
|
||||||
@@ -67,25 +41,31 @@ export class MeshCoreStream extends BaseStream {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractPacketBytes(payload: Uint8Array): { bytes: Uint8Array; snr?: number; rssi?: number } {
|
private extractPacketBytes(payload: Uint8Array): { bytes: Uint8Array; snr?: number; rssi?: number; time?: Date } {
|
||||||
const text = new TextDecoder().decode(payload).trim();
|
const text = new TextDecoder().decode(payload).trim();
|
||||||
if (!text.startsWith('{')) {
|
if (!text.startsWith('{')) {
|
||||||
return { bytes: payload };
|
return { bytes: payload };
|
||||||
}
|
}
|
||||||
|
|
||||||
const envelope = JSON.parse(text) as MeshCoreJsonEnvelope;
|
const envelope = JSON.parse(text) as StreamPacket;
|
||||||
let bytes: Uint8Array = payload;
|
let bytes: Uint8Array = payload;
|
||||||
|
let time: Date | undefined = undefined;
|
||||||
|
|
||||||
if (envelope.payloadBase64) {
|
if (envelope.raw) {
|
||||||
bytes = Uint8Array.from(atob(envelope.payloadBase64), (c) => c.charCodeAt(0));
|
bytes = base64ToBytes(envelope.raw);
|
||||||
} else if (envelope.payloadHex) {
|
}
|
||||||
bytes = hexToBytes(envelope.payloadHex);
|
|
||||||
|
if (envelope.time) {
|
||||||
|
time = new Date(envelope.time);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bytes,
|
bytes,
|
||||||
|
time,
|
||||||
snr: envelope.snr,
|
snr: envelope.snr,
|
||||||
rssi: envelope.rssi,
|
rssi: envelope.rssi,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default MeshCoreStream;
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import mqtt from "mqtt";
|
|||||||
import type { StreamConnectionOptions, StreamState, TopicSubscription } from "../types/stream.types";
|
import type { StreamConnectionOptions, StreamState, TopicSubscription } from "../types/stream.types";
|
||||||
|
|
||||||
const defaultConnectionOptions: StreamConnectionOptions = {
|
const defaultConnectionOptions: StreamConnectionOptions = {
|
||||||
url: import.meta.env.DEV
|
url: import.meta.env.DEV ?
|
||||||
? 'ws://10.42.23.73:8083'
|
'wss://pd0mz.hamnet.nl/broker' :
|
||||||
: ((window.location.protocol === 'http:') ? 'ws:' : 'wss:') + '//' + window.location.host + '/broker'
|
((window.location.protocol === 'http:') ? 'ws:' : 'wss:') + '//' + window.location.host + '/broker'
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class BaseStream {
|
export abstract class BaseStream {
|
||||||
@@ -12,7 +12,7 @@ export abstract class BaseStream {
|
|||||||
protected connectionOptions: StreamConnectionOptions;
|
protected connectionOptions: StreamConnectionOptions;
|
||||||
protected subscribers: Map<string, Set<(data: any, topic: string) => void>> = new Map();
|
protected subscribers: Map<string, Set<(data: any, topic: string) => void>> = new Map();
|
||||||
protected stateSubscribers: Set<(state: StreamState) => void> = new Set();
|
protected stateSubscribers: Set<(state: StreamState) => void> = new Set();
|
||||||
protected reconnectTimer: NodeJS.Timeout | null = null;
|
protected reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
protected autoConnect: boolean;
|
protected autoConnect: boolean;
|
||||||
|
|
||||||
protected state: StreamState = {
|
protected state: StreamState = {
|
||||||
@@ -48,7 +48,9 @@ export abstract class BaseStream {
|
|||||||
try {
|
try {
|
||||||
const randomId = Math.random().toString(16).slice(2, 10);
|
const randomId = Math.random().toString(16).slice(2, 10);
|
||||||
const prefix = import.meta.env.DEV ? 'dev_' : '';
|
const prefix = import.meta.env.DEV ? 'dev_' : '';
|
||||||
const defaultClientId = `${prefix}hamview_${randomId}`;
|
const defaultClientId = `${prefix}web_${randomId}`;
|
||||||
|
|
||||||
|
console.log(`Connecting to MQTT broker at ${this.connectionOptions.url} with clientId ${defaultClientId}`);
|
||||||
|
|
||||||
this.client = mqtt.connect(this.connectionOptions.url, {
|
this.client = mqtt.connect(this.connectionOptions.url, {
|
||||||
...this.connectionOptions.options,
|
...this.connectionOptions.options,
|
||||||
|
|||||||
277
ui/src/styles/ProtocolBriefing.scss
Normal file
277
ui/src/styles/ProtocolBriefing.scss
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
// Shared briefing styles for protocol landing pages (APRS, MeshCore, etc.)
|
||||||
|
|
||||||
|
.protocol-briefing {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-briefing-hero {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(173, 205, 255, 0.28);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(120deg, rgba(64, 169, 255, 0.12), rgba(147, 197, 253, 0.04));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: var(--app-text);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-briefing-hero-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-briefing-hero-content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-briefing-hero-logo-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-briefing-hero-logo {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 180px;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(173, 205, 255, 0.28);
|
||||||
|
background: rgba(9, 31, 66, 0.55);
|
||||||
|
padding: 0.55rem;
|
||||||
|
box-shadow: 0 10px 20px rgba(2, 10, 26, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-briefing-hero-logo-label {
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-briefing-signal-row {
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-briefing-signal-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
border: 1px solid rgba(173, 205, 255, 0.25);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
background: rgba(11, 39, 82, 0.38);
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-briefing-kpis {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-briefing-kpi-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
border-left: 3px solid rgba(64, 169, 255, 0.75);
|
||||||
|
background: linear-gradient(180deg, rgba(13, 36, 82, 0.42), rgba(13, 36, 82, 0.14));
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-briefing-kpis .data-table-card:nth-child(2) .protocol-briefing-kpi-card {
|
||||||
|
border-left-color: rgba(115, 209, 61, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-briefing-kpis .data-table-card:nth-child(3) .protocol-briefing-kpi-card {
|
||||||
|
border-left-color: rgba(250, 173, 20, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-briefing-kpis .data-table-card:nth-child(4) .protocol-briefing-kpi-card {
|
||||||
|
border-left-color: rgba(146, 84, 222, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-briefing-kpi-label {
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-briefing-kpi-value {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--app-text);
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 0.2rem 0 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-briefing-radio-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-briefing-radio-item {
|
||||||
|
border: 1px solid rgba(173, 205, 255, 0.22);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
background: rgba(11, 39, 82, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-briefing-radio-item--clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease, background-color 0.15s ease, transform 0.15s ease;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
border-color: rgba(173, 205, 255, 0.55);
|
||||||
|
background: rgba(11, 39, 82, 0.38);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-briefing-radio-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
color: var(--app-text);
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-briefing-radio-image {
|
||||||
|
width: 3.2rem;
|
||||||
|
height: 3.2rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
object-fit: contain;
|
||||||
|
border: 1px solid rgba(173, 205, 255, 0.25);
|
||||||
|
background: rgba(9, 31, 66, 0.45);
|
||||||
|
padding: 0.35rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-briefing-radio-title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0.05rem;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-briefing-radio-dot {
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
|
||||||
|
&.is-online {
|
||||||
|
background: #73d13d;
|
||||||
|
box-shadow: 0 0 0 2px rgba(115, 209, 61, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-offline {
|
||||||
|
background: #8c8c8c;
|
||||||
|
box-shadow: 0 0 0 2px rgba(140, 140, 140, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-briefing-radio-meta {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-briefing-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-briefing-list {
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li + li {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--app-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.protocol-briefing-kpis {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.protocol-briefing-hero-layout {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-briefing-hero-logo-wrap {
|
||||||
|
align-items: flex-start;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-briefing-columns {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.protocol-briefing {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-briefing-kpis {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,13 @@
|
|||||||
THEME STYLES - Reusable Typography & Colors
|
THEME STYLES - Reusable Typography & Colors
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
@import './theme/typography';
|
@use './theme/typography' as *;
|
||||||
@import './theme/buttons';
|
@use './theme/buttons' as *;
|
||||||
@import './theme/badges';
|
@use './theme/badges' as *;
|
||||||
@import './theme/tags';
|
@use './theme/tags' as *;
|
||||||
@import './theme/forms';
|
@use './theme/forms' as *;
|
||||||
@import './theme/code';
|
@use './theme/code' as *;
|
||||||
@import './theme/tables';
|
@use './theme/tables' as *;
|
||||||
@import './theme/utilities';
|
@use './theme/utilities' as *;
|
||||||
|
@use './theme/charts' as *;
|
||||||
|
@use './theme/bootstrap-overrides' as *;
|
||||||
|
|||||||
147
ui/src/styles/theme/_bootstrap-overrides.scss
Normal file
147
ui/src/styles/theme/_bootstrap-overrides.scss
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
// Bootstrap font size overrides for smaller default text
|
||||||
|
html, body {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally, adjust headings and other elements if needed
|
||||||
|
h1 { font-size: 2rem; }
|
||||||
|
h2 { font-size: 1.5rem; }
|
||||||
|
h3 { font-size: 1.17rem; }
|
||||||
|
h4 { font-size: 1rem; }
|
||||||
|
h5 { font-size: 0.83rem; }
|
||||||
|
h6 { font-size: 0.67rem; }
|
||||||
|
|
||||||
|
// Bootstrap utility classes
|
||||||
|
.small, .text-small, .form-text, .form-label, .form-control, .btn, .nav-link, .dropdown-item {
|
||||||
|
font-size: 0.92em;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap table color variable overrides for dark blue theme
|
||||||
|
:root, .table {
|
||||||
|
--bs-table-bg: #162447;
|
||||||
|
--bs-table-striped-bg: #1f4068;
|
||||||
|
--bs-table-striped-color: #f8f9fa;
|
||||||
|
--bs-table-active-bg: #23395d;
|
||||||
|
--bs-table-active-color: #f8f9fa;
|
||||||
|
--bs-table-hover-bg: #23395d;
|
||||||
|
--bs-table-hover-color: #f8f9fa;
|
||||||
|
--bs-table-border-color: #23395d;
|
||||||
|
--bs-table-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default table style for dark blue theme
|
||||||
|
table {
|
||||||
|
background-color: #162447;
|
||||||
|
color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
background-color: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead {
|
||||||
|
background-color: #1f4068;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
border-bottom: 1px solid #23395d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make bootstrap monospace utility and code elements slightly smaller
|
||||||
|
.font-monospace,
|
||||||
|
code,
|
||||||
|
kbd,
|
||||||
|
samp {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1;
|
||||||
|
vertical-align: middle;
|
||||||
|
color: var(--app-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content a {
|
||||||
|
color: var(--app-accent-yellow) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dropdown overrides to match global app theme
|
||||||
|
.dropdown,
|
||||||
|
.dropup,
|
||||||
|
.dropleft,
|
||||||
|
.dropright {
|
||||||
|
// ensure dropdown toggles use accent color for icons/links
|
||||||
|
.dropdown-toggle {
|
||||||
|
color: var(--app-accent-primary);
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
color: var(--app-accent-blue);
|
||||||
|
background: transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
background-color: var(--app-bg-elevated);
|
||||||
|
color: var(--app-text);
|
||||||
|
border: 1px solid var(--app-border-color);
|
||||||
|
box-shadow: 0 6px 18px rgba(2,10,26,0.6);
|
||||||
|
min-width: 10rem;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
color: var(--app-text);
|
||||||
|
padding: 0.375rem 1rem;
|
||||||
|
font-size: 0.92em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover,
|
||||||
|
.dropdown-item:focus,
|
||||||
|
.dropdown-item.active {
|
||||||
|
background-color: var(--app-button-hover);
|
||||||
|
color: var(--app-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure svg icons inside dropdown toggles inherit theme color
|
||||||
|
.dropdown-toggle svg {
|
||||||
|
color: currentColor;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smaller caret spacing for compact header controls
|
||||||
|
.data-table-header .dropdown-toggle {
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
color: var(--app-accent-yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table-header .dropdown-toggle:hover,
|
||||||
|
.data-table-header .dropdown-toggle:focus {
|
||||||
|
color: var(--app-accent-yellow-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Some Dropdown.Toggle instances use custom bsPrefix and don't include
|
||||||
|
the `dropdown-toggle` class. Target the header button by id and title
|
||||||
|
so the icon and button color are always visible. */
|
||||||
|
.data-table-header button#type-filter-toggle-header,
|
||||||
|
.data-table-header button#type-filter-toggle,
|
||||||
|
.data-table-header button[title="Filter by type"] {
|
||||||
|
color: var(--app-accent-yellow) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table-header button#type-filter-toggle-header:hover,
|
||||||
|
.data-table-header button#type-filter-toggle:hover,
|
||||||
|
.data-table-header button[title="Filter by type"]:hover {
|
||||||
|
color: var(--app-accent-yellow-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table-header button#type-filter-toggle-header svg,
|
||||||
|
.data-table-header button#type-filter-toggle svg,
|
||||||
|
.data-table-header button[title="Filter by type"] svg {
|
||||||
|
color: currentColor;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
47
ui/src/styles/theme/_charts.scss
Normal file
47
ui/src/styles/theme/_charts.scss
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/* ============================================
|
||||||
|
CHARTS - Global defaults for MUI X Charts
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.MuiChartsSurface-root,
|
||||||
|
.MuiChartsWrapper-root {
|
||||||
|
color: var(--app-text-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.MuiChartsAxis-line,
|
||||||
|
.MuiChartsAxis-tick,
|
||||||
|
.MuiChartsGrid-line {
|
||||||
|
stroke: rgba(173, 205, 255, 0.32) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.MuiChartsAxis-tickLabel,
|
||||||
|
.MuiChartsLegend-label,
|
||||||
|
.MuiChartsAxis-label,
|
||||||
|
.MuiChartsLabel-root {
|
||||||
|
fill: var(--app-text-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.MuiChartsTooltip-root,
|
||||||
|
.MuiChartsTooltip-paper,
|
||||||
|
.MuiChartsTooltip-table,
|
||||||
|
.MuiChartsTooltip-row,
|
||||||
|
.MuiChartsTooltip-cell,
|
||||||
|
.MuiChartsTooltip-labelCell,
|
||||||
|
.MuiChartsTooltip-valueCell,
|
||||||
|
.MuiChartsTooltip-axisValueCell {
|
||||||
|
color: var(--app-text-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.MuiChartsLegend-root {
|
||||||
|
background: rgba(11, 39, 82, 0.45);
|
||||||
|
border: 1px solid rgba(173, 205, 255, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.MuiChartsLegend-series {
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.MuiChartsLegend-mark {
|
||||||
|
filter: drop-shadow(0 0 3px rgba(2, 10, 26, 0.6));
|
||||||
|
}
|
||||||
@@ -32,42 +32,5 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.data-table {
|
.data-table {
|
||||||
color: var(--app-text);
|
/* Color and background overrides removed to use default/inherited styles */
|
||||||
|
|
||||||
thead th {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 2;
|
|
||||||
background: rgba(13, 36, 82, 0.95);
|
|
||||||
border-color: rgba(173, 205, 255, 0.18);
|
|
||||||
color: var(--app-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
border-color: rgba(173, 205, 255, 0.12);
|
|
||||||
vertical-align: middle;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr.is-selected td {
|
|
||||||
background: rgba(102, 157, 255, 0.34);
|
|
||||||
color: #f2f7ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr.is-selected a,
|
|
||||||
tr.is-selected a:hover {
|
|
||||||
color: var(--app-accent-yellow);
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:hover td {
|
|
||||||
background: rgba(102, 157, 255, 0.08);
|
|
||||||
color: var(--app-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:hover .callsign {
|
|
||||||
color: var(--app-accent-yellow);
|
|
||||||
background-color: var(--app-blue-dark);
|
|
||||||
border-color: var(--app-accent-yellow);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export interface FullProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type VerticalSplitRatio = '1:1' | '3:1' | '2:1' | '25/70';
|
export type VerticalSplitRatio = '1:1' | '3:1' | '2:1' | '25/70' | '25:75';
|
||||||
|
|
||||||
export interface VerticalSplitProps {
|
export interface VerticalSplitProps {
|
||||||
left?: React.ReactNode;
|
left?: React.ReactNode;
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export const toModulationDisplayName = (modulation: string): string => {
|
|||||||
* Base packet interface with common fields from all protocol types.
|
* Base packet interface with common fields from all protocol types.
|
||||||
* These fields are typically extracted from Go's protocol.Packet struct.
|
* These fields are typically extracted from Go's protocol.Packet struct.
|
||||||
*/
|
*/
|
||||||
export interface Packet {
|
export interface PacketMeta {
|
||||||
/** When the packet was received */
|
/** When the packet was received */
|
||||||
receivedAt: Date;
|
receivedAt: Date;
|
||||||
/** Signal-to-Noise Ratio in dB */
|
/** Signal-to-Noise Ratio in dB */
|
||||||
@@ -43,3 +43,17 @@ export interface Packet {
|
|||||||
/** Name/ID of the radio that received the packet */
|
/** Name/ID of the radio that received the packet */
|
||||||
radioName?: string;
|
radioName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class PacketInfo implements PacketMeta {
|
||||||
|
public receivedAt: Date = new Date();
|
||||||
|
public snr?: number;
|
||||||
|
public rssi?: number;
|
||||||
|
public radioName?: string;
|
||||||
|
|
||||||
|
constructor(meta: PacketMeta) {
|
||||||
|
this.receivedAt = meta.receivedAt;
|
||||||
|
this.snr = meta.snr;
|
||||||
|
this.rssi = meta.rssi;
|
||||||
|
this.radioName = meta.radioName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
129
ui/src/types/protocol/adsb.types.ts
Normal file
129
ui/src/types/protocol/adsb.types.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import type { Segment } from './dissection.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADSB Message Types (DF - Downlink Format)
|
||||||
|
*/
|
||||||
|
export const ADSBMessageType = {
|
||||||
|
SURVEILLANCE_REPLY: 0, // DF0
|
||||||
|
IDENTIFICATION_REPLY: 4, // DF4
|
||||||
|
SURVEILLANCE_ALTITUDE: 5, // DF5
|
||||||
|
IDENTIFICATION: 11, // DF11
|
||||||
|
SURVEILLANCE_POSITION: 17, // DF17
|
||||||
|
SURVEILLANCE_POSITION_ALT: 18, // DF18
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ADSBMessageType = typeof ADSBMessageType[keyof typeof ADSBMessageType] | number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADSB TC (Type Code) for DF17/18
|
||||||
|
*/
|
||||||
|
export const ADSBTypeCode = {
|
||||||
|
IDENTIFICATION_AND_CATEGORY: 1,
|
||||||
|
SURFACE_POSITION: 5,
|
||||||
|
ALTITUDE_BAROMETRIC: 11,
|
||||||
|
ALTITUDE_GEOMETRIC: 20,
|
||||||
|
AIRBORNE_VELOCITY: 19,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ADSBTypeCode = typeof ADSBTypeCode[keyof typeof ADSBTypeCode] | number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aircraft identification payload
|
||||||
|
*/
|
||||||
|
export interface IdentificationPayload {
|
||||||
|
type: 'identification';
|
||||||
|
callsign?: string; // Aircraft identification
|
||||||
|
category?: string; // Aircraft category
|
||||||
|
icao?: string; // ICAO address
|
||||||
|
sections?: Segment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position payload (surface or airborne)
|
||||||
|
*/
|
||||||
|
export interface PositionPayload {
|
||||||
|
type: 'position';
|
||||||
|
icao?: string; // ICAO address
|
||||||
|
latitude?: number; // Decimal degrees
|
||||||
|
longitude?: number; // Decimal degrees
|
||||||
|
altitude?: number; // Feet
|
||||||
|
altitudeType?: 'barometric' | 'geometric';
|
||||||
|
groundSpeed?: number; // Knots
|
||||||
|
trackAngle?: number; // Degrees
|
||||||
|
verticalRate?: number; // Feet per minute
|
||||||
|
onGround?: boolean; // Whether aircraft is on ground
|
||||||
|
sections?: Segment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Velocity payload
|
||||||
|
*/
|
||||||
|
export interface VelocityPayload {
|
||||||
|
type: 'velocity';
|
||||||
|
icao?: string; // ICAO address
|
||||||
|
groundSpeed?: number; // Knots
|
||||||
|
trackAngle?: number; // Degrees (true north)
|
||||||
|
verticalRate?: number; // Feet per minute
|
||||||
|
verticalRateSource?: 'barometric' | 'geometric';
|
||||||
|
headingType?: 'computed' | 'magnetic';
|
||||||
|
speedSource?: 'computed' | 'gps';
|
||||||
|
sections?: Segment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surface position payload
|
||||||
|
*/
|
||||||
|
export interface SurfacePositionPayload {
|
||||||
|
type: 'surface-position';
|
||||||
|
icao?: string; // ICAO address
|
||||||
|
latitude?: number; // Decimal degrees
|
||||||
|
longitude?: number; // Decimal degrees
|
||||||
|
groundSpeed?: number; // Knots
|
||||||
|
trackAngle?: number; // Degrees
|
||||||
|
sections?: Segment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Altitude payload (barometric or geometric)
|
||||||
|
*/
|
||||||
|
export interface AltitudePayload {
|
||||||
|
type: 'altitude';
|
||||||
|
icao?: string; // ICAO address
|
||||||
|
altitude?: number; // Feet
|
||||||
|
altitudeType?: 'barometric' | 'geometric';
|
||||||
|
sections?: Segment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emergency/Priority payload
|
||||||
|
*/
|
||||||
|
export interface EmergencyPayload {
|
||||||
|
type: 'emergency';
|
||||||
|
icao?: string; // ICAO address
|
||||||
|
emergencyState?: string; // Emergency declaration
|
||||||
|
sections?: Segment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union type for all decoded ADSB payload types
|
||||||
|
*/
|
||||||
|
export type DecodedPayload =
|
||||||
|
| IdentificationPayload
|
||||||
|
| PositionPayload
|
||||||
|
| VelocityPayload
|
||||||
|
| SurfacePositionPayload
|
||||||
|
| AltitudePayload
|
||||||
|
| EmergencyPayload;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADSB Frame structure
|
||||||
|
*/
|
||||||
|
export interface Frame {
|
||||||
|
messageType: ADSBMessageType;
|
||||||
|
icao?: string; // 24-bit ICAO address
|
||||||
|
typeCode?: ADSBTypeCode; // Type code for DF17/18
|
||||||
|
payload: DecodedPayload;
|
||||||
|
raw: Uint8Array;
|
||||||
|
crc?: number; // Cyclic redundancy check
|
||||||
|
segments?: Segment[]; // Dissection segments
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||||
import type { Segment } from './dissection.types';
|
import type { Segment } from './dissection.types';
|
||||||
|
import { type PacketMeta, PacketInfo } from '../protocol.types';
|
||||||
|
|
||||||
export type NodeHash = string; // first byte of the hash
|
export type NodeHash = string; // first byte of the hash
|
||||||
|
|
||||||
@@ -11,6 +13,13 @@ export const RouteType = {
|
|||||||
|
|
||||||
export type RouteType = typeof RouteType[keyof typeof RouteType] | number;
|
export type RouteType = typeof RouteType[keyof typeof RouteType] | number;
|
||||||
|
|
||||||
|
export const routeDisplayByValue: Record<number, string> = {
|
||||||
|
[RouteType.TRANSPORT_FLOOD]: 'Flood (T)',
|
||||||
|
[RouteType.FLOOD]: 'Flood',
|
||||||
|
[RouteType.DIRECT]: 'Direct',
|
||||||
|
[RouteType.TRANSPORT_DIRECT]: 'Direct (T)',
|
||||||
|
};
|
||||||
|
|
||||||
export const PayloadType = {
|
export const PayloadType = {
|
||||||
REQUEST: 0x00, // request (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
|
REQUEST: 0x00, // request (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
|
||||||
RESPONSE: 0x01, // response to REQ or ANON_REQ (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
|
RESPONSE: 0x01, // response to REQ or ANON_REQ (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
|
||||||
@@ -30,6 +39,22 @@ export const PayloadType = {
|
|||||||
export type PayloadTypeValue = typeof PayloadType[keyof typeof PayloadType];
|
export type PayloadTypeValue = typeof PayloadType[keyof typeof PayloadType];
|
||||||
export type PayloadType = PayloadTypeValue | number;
|
export type PayloadType = PayloadTypeValue | number;
|
||||||
|
|
||||||
|
export const payloadNameByValue: Record<number, string> = {
|
||||||
|
[PayloadType.REQUEST]: 'Request',
|
||||||
|
[PayloadType.RESPONSE]: 'Response',
|
||||||
|
[PayloadType.TEXT]: 'Text Message',
|
||||||
|
[PayloadType.ACK]: 'Ack',
|
||||||
|
[PayloadType.ADVERT]: 'Advertisement',
|
||||||
|
[PayloadType.GROUP_TEXT]: 'Group Text Message',
|
||||||
|
[PayloadType.GROUP_DATA]: 'Group Data Message',
|
||||||
|
[PayloadType.ANON_REQ]: 'Anonymous Request',
|
||||||
|
[PayloadType.PATH]: 'Path Info',
|
||||||
|
[PayloadType.TRACE]: 'Trace Info',
|
||||||
|
[PayloadType.MULTIPART]: 'Multipart Packet',
|
||||||
|
[PayloadType.CONTROL]: 'Control Packet',
|
||||||
|
[PayloadType.RAW_CUSTOM]: 'Raw Custom Payload',
|
||||||
|
};
|
||||||
|
|
||||||
export interface Packet {
|
export interface Packet {
|
||||||
version: number;
|
version: number;
|
||||||
transportCodes?: Uint16Array;
|
transportCodes?: Uint16Array;
|
||||||
@@ -40,19 +65,66 @@ export interface Packet {
|
|||||||
sections?: Segment[];
|
sections?: Segment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class BasePacket {
|
export abstract class BasePacket extends PacketInfo {
|
||||||
protected version: number = 1;
|
public header: number;
|
||||||
protected transportCodes?: Uint16Array;
|
public transportCodes?: Uint16Array;
|
||||||
protected routeType: number = 0;
|
public pathLength: number;
|
||||||
protected payloadType: number = 0;
|
public path: Uint8Array;
|
||||||
protected pathLength: number = 0;
|
public payload: Uint8Array;
|
||||||
protected path: Uint8Array = new Uint8Array();
|
|
||||||
protected payload: Uint8Array = new Uint8Array();
|
|
||||||
|
|
||||||
abstract getPathHashSize(): number;
|
constructor(
|
||||||
abstract getPathHashCount(): number;
|
header: number,
|
||||||
abstract hash(): Uint8Array;
|
transportCodes: Uint16Array | undefined,
|
||||||
abstract decode(): Payload;
|
pathLength: number,
|
||||||
|
path: Uint8Array,
|
||||||
|
payload: Uint8Array,
|
||||||
|
info: PacketMeta
|
||||||
|
) {
|
||||||
|
super(info);
|
||||||
|
this.header = header;
|
||||||
|
this.transportCodes = transportCodes;
|
||||||
|
this.pathLength = pathLength;
|
||||||
|
this.path = path;
|
||||||
|
this.payload = payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getRouteType(): RouteType {
|
||||||
|
return (this.header >> 0) & 0x03;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getPayloadVersion(): number {
|
||||||
|
return (this.header >> 6) & 0x03;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getPayloadType(): PayloadType {
|
||||||
|
return (this.header >> 2) & 0x0F;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getPathHashSize(): number {
|
||||||
|
return (this.pathLength >> 6) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getPathHashCount(): number {
|
||||||
|
return this.pathLength & 0x3F;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getPathBytesLength(): number {
|
||||||
|
return this.getPathHashSize() * this.getPathHashCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
public getPathHashes(): NodeHash[] {
|
||||||
|
const hashSize = this.getPathHashSize();
|
||||||
|
const hashCount = this.getPathHashCount();
|
||||||
|
const hashes: NodeHash[] = [];
|
||||||
|
for (let i = 0; i < hashCount; i++) {
|
||||||
|
const hashBytes = this.path.slice(i * hashSize, (i + 1) * hashSize);
|
||||||
|
hashes.push(bytesToHex(hashBytes));
|
||||||
|
}
|
||||||
|
return hashes;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract decode(emitSections: boolean): Payload;
|
||||||
|
abstract hash(): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Payload =
|
export type Payload =
|
||||||
@@ -173,10 +245,9 @@ export interface SignedTextMessage extends DecryptedTextMessage {
|
|||||||
senderPubkeyPrefix: Uint8Array; // First 4 bytes of sender pubkey (when txt_type = 0x02)
|
senderPubkeyPrefix: Uint8Array; // First 4 bytes of sender pubkey (when txt_type = 0x02)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AckPayload extends EncryptedPayload {
|
export interface AckPayload {
|
||||||
readonly payloadType: typeof PayloadType.ACK;
|
readonly payloadType: typeof PayloadType.ACK;
|
||||||
dstHash: NodeHash;
|
checksum: Uint8Array; // 4 bytes, LE
|
||||||
srcHash: NodeHash;
|
|
||||||
sections?: Segment[];
|
sections?: Segment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,8 +368,9 @@ export interface DecryptedPath {
|
|||||||
|
|
||||||
export interface TracePayload {
|
export interface TracePayload {
|
||||||
readonly payloadType: typeof PayloadType.TRACE;
|
readonly payloadType: typeof PayloadType.TRACE;
|
||||||
// Format not fully specified in docs - collecting SNI for each hop
|
tag: number; // 4 bytes, LE - used to correlate trace requests and responses
|
||||||
data: Uint8Array;
|
authCode: Uint8Array; // 4 bytes - HMAC or similar to prevent spoofing
|
||||||
|
nodes: Uint8Array;
|
||||||
sections?: Segment[];
|
sections?: Segment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,11 +426,6 @@ export interface RawCustomPayload {
|
|||||||
sections?: Segment[];
|
sections?: Segment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Group {
|
|
||||||
name: string;
|
|
||||||
secret: BaseGroupSecret;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PublicKeyValue = string | Uint8Array
|
export type PublicKeyValue = string | Uint8Array
|
||||||
|
|
||||||
export abstract class BasePublicKey {
|
export abstract class BasePublicKey {
|
||||||
|
|||||||
@@ -24,4 +24,13 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
chunkFileNames: 'assets/[hash].js',
|
||||||
|
entryFileNames: 'assets/[hash].js',
|
||||||
|
assetFileNames: 'assets/[hash][extname]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user