Checkpoint

This commit is contained in:
2026-03-08 22:22:51 +01:00
parent 247c827291
commit 9053ec65a6
65 changed files with 5874 additions and 708 deletions

View File

@@ -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.

View File

@@ -2,9 +2,9 @@
<html lang="en">
<head>
<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" />
<title>HAMView</title>
<title>DigiView [PD0MZ]</title>
</head>
<body>
<div id="root"></div>

319
ui/package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": {
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.8",
"@mui/x-charts": "^8.27.4",
"@noble/ciphers": "^2.1.1",
"@noble/curves": "^2.0.1",
"@noble/ed25519": "^3.0.0",
@@ -1223,6 +1224,7 @@
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.8.tgz",
"integrity": "sha512-QKd1RhDXE1hf2sQDNayA9ic9jGkEgvZOf0tTkJxlBPG8ns8aS4rS8WwYURw2x5y3739p0HauUXX9WbH7UufFLw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.6",
"@mui/core-downloads-tracker": "^7.3.8",
@@ -1339,6 +1341,7 @@
"resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.8.tgz",
"integrity": "sha512-hoFRj4Zw2Km8DPWZp/nKG+ao5Jw5LSk2m/e4EGc6M3RRwXKEkMSG4TgtfVJg7dS2homRwtdXSMW+iRO0ZJ4+IA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.6",
"@mui/private-theming": "^7.3.8",
@@ -1427,6 +1430,105 @@
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
"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": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
@@ -2319,6 +2421,75 @@
"dev": true,
"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": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -3057,6 +3228,12 @@
"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": {
"version": "6.1.6",
"resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz",
@@ -3454,6 +3631,118 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"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": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
@@ -4018,6 +4307,12 @@
"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": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz",
@@ -4349,6 +4644,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"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": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
@@ -5203,6 +5507,12 @@
"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": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -5723,6 +6033,15 @@
"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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@@ -15,6 +15,7 @@
"dependencies": {
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.8",
"@mui/x-charts": "^8.27.4",
"@noble/ciphers": "^2.1.1",
"@noble/curves": "^2.0.1",
"@noble/ed25519": "^3.0.0",

View 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
View 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

View File

@@ -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

View File

@@ -1,6 +1,6 @@
/* Import theme configuration */
@import './styles/variables';
@import './styles/theme';
@use './styles/variables' as *;
@use './styles/theme' as *;
html,
body,
@@ -24,6 +24,10 @@ body {
color: var(--app-text);
}
.text-secondary {
color: var(--app-blue-light) !important;
}
a {
color: var(--app-accent);
}
@@ -33,10 +37,13 @@ a:hover {
}
.full-view {
flex: 1 1 auto;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
}
.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-item {
padding: 0.75rem;

View File

@@ -1,22 +1,31 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router'
import { Suspense, lazy } from 'react'
import LoadingFallback from './components/LoadingFallback'
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 './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 = [
{ label: 'Radios', to: '/' },
{ label: 'APRS', to: '/aprs' },
{ label: 'ADSB', to: '/adsb' },
{ label: 'MeshCore', to: '/meshcore' },
];
@@ -31,21 +40,29 @@ function App() {
return (
<KeyboardNavigationProvider>
<BrowserRouter>
<Suspense fallback={<LoadingFallback />}>
<Routes>
<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 index element={<Navigate to="packets" replace />} />
<Route index element={<APRSView />} />
<Route path="packets" element={<APRSPacketsView />} />
</Route>
<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="groupchat" element={<MeshCoreGroupChatView />} />
<Route path="nodes" element={<MeshCoreNodesView />} />
<Route path="map" element={<MeshCoreMapView />} />
</Route>
<Route path="/style-guide" element={<StyleGuide />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</BrowserRouter>
</KeyboardNavigationProvider>
)

View File

@@ -4,7 +4,7 @@ import type { FullProps } from '../types/layout.types';
const Full: React.FC<FullProps> = ({ children, className = '' }) => {
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}
</Container>
);

View File

@@ -92,6 +92,7 @@
box-sizing: border-box;
background-color: var(--app-bg);
color: var(--app-text);
padding-top: var(--layout-gutter);
padding-left: var(--layout-gutter);
padding-right: var(--layout-gutter);
padding-bottom: var(--layout-gutter);

View File

@@ -4,20 +4,21 @@ import { Link, NavLink, Outlet, useLocation } from 'react-router';
import KeyboardIcon from '@mui/icons-material/Keyboard';
import { useKeyboardNavigationActivity } from '../contexts/KeyboardNavigationContext';
import KeyboardShortcutsModal from './KeyboardShortcutsModal';
import Full from './Full';
import { defaultKeyboardShortcuts } from '../hooks/useKeyboardListNavigation';
import './Layout.scss';
import type { LayoutProps, NavLinkItem } from '../types/layout.types';
const Layout: React.FC<LayoutProps> = ({
children,
brandText = 'PD0MZ HAM View',
brandText = 'PD0MZ Digi View',
brandTo = '/',
buttonGroup,
navLinks = [],
gutterSize = 16
}) => {
const location = useLocation();
const { isKeyboardNavigationActive, showGlobalShortcuts, setShowGlobalShortcuts } = useKeyboardNavigationActivity();
const { isKeyboardNavigationActive, showGlobalShortcuts, setShowGlobalShortcuts, registeredShortcuts } = useKeyboardNavigationActivity();
const isActive = (link: NavLinkItem): boolean => {
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">
<Container fluid>
<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}
</Navbar.Brand>
{buttonGroup && <div className="ms-3">{buttonGroup}</div>}
@@ -72,15 +74,28 @@ const Layout: React.FC<LayoutProps> = ({
</Container>
</Navbar>
<Container fluid className="main-content d-flex flex-column" style={{ marginTop: resolvedGutter }}>
<Full className="main-content">
{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
show={showGlobalShortcuts}
onHide={() => setShowGlobalShortcuts(false)}
shortcuts={defaultKeyboardShortcuts}
shortcuts={unique}
/>
);
})()}
<footer className="layout-footer">
<p>&copy; {new Date().getFullYear()} PD0MZ. All rights reserved.</p>

View 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);
}
}

View 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>&copy; {new Date().getFullYear()} PD0MZ. All rights reserved.</p>
</footer>
</div>
)
}

View File

@@ -327,6 +327,7 @@ const PacketDissectionViewer: React.FC<PacketDissectionViewerProps> = ({
);
}
// console.log(`Rendering PacketDissectionViewer with ${bytes.length} bytes and ${segments.length} segments`);
return (
<div className="packet-dissection-viewer">
<h6 className="packet-dissection-title">{title}</h6>

View 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);
}

View 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;

View File

@@ -11,6 +11,7 @@ const VerticalSplit: React.FC<VerticalSplitProps> = ({
const ratioClass =
ratio === '3:1' ? 'vertical-split-3-1' :
ratio === '2:1' ? 'vertical-split-2-1' :
ratio === '25:75' ? 'vertical-split-25-75' :
ratio === '25/70' ? 'vertical-split-25-70' :
'vertical-split-1-1';

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -1,4 +1,5 @@
import React from 'react';
import type { KeyboardShortcut } from '../hooks/useKeyboardListNavigation';
type KeyboardNavigationContextValue = {
isKeyboardNavigationActive: boolean;
@@ -6,6 +7,9 @@ type KeyboardNavigationContextValue = {
deactivateKeyboardNavigation: () => void;
showGlobalShortcuts: boolean;
setShowGlobalShortcuts: (show: boolean) => void;
registerShortcuts: (id: string, shortcuts: KeyboardShortcut[]) => void;
unregisterShortcuts: (id: string) => void;
registeredShortcuts: KeyboardShortcut[];
};
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 }) => {
const [activeCount, setActiveCount] = React.useState(0);
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(() => {
setActiveCount((count) => count + 1);
@@ -28,7 +45,10 @@ export const KeyboardNavigationProvider: React.FC<{ children: React.ReactNode }>
deactivateKeyboardNavigation,
showGlobalShortcuts,
setShowGlobalShortcuts,
}), [activeCount, activateKeyboardNavigation, deactivateKeyboardNavigation, showGlobalShortcuts]);
registerShortcuts,
unregisterShortcuts,
registeredShortcuts: Object.values(registeredMap).flat(),
}), [activeCount, activateKeyboardNavigation, deactivateKeyboardNavigation, showGlobalShortcuts, registerShortcuts, unregisterShortcuts, registeredMap]);
return (
<KeyboardNavigationContext.Provider value={value}>

View File

@@ -21,6 +21,8 @@ type UseKeyboardListNavigationOptions = {
scrollContainerRef: React.RefObject<HTMLElement | null>;
rowSelector?: string;
enabled?: boolean;
onPrevPage?: () => void;
onNextPage?: () => void;
shortcuts?: KeyboardShortcut[];
};
@@ -40,10 +42,11 @@ export const useKeyboardListNavigation = ({
scrollContainerRef,
rowSelector = '[data-nav-item="true"]',
enabled = true,
onPrevPage,
onNextPage,
shortcuts = DEFAULT_SHORTCUTS,
}: UseKeyboardListNavigationOptions) => {
const [showShortcuts, setShowShortcuts] = React.useState(false);
const { activateKeyboardNavigation, deactivateKeyboardNavigation } = useKeyboardNavigationActivity();
const { activateKeyboardNavigation, deactivateKeyboardNavigation, showGlobalShortcuts, setShowGlobalShortcuts, registerShortcuts, unregisterShortcuts } = useKeyboardNavigationActivity();
React.useEffect(() => {
if (!enabled) {
@@ -96,7 +99,7 @@ export const useKeyboardListNavigation = ({
const isQuestionMark = event.key === '?' || (event.key === '/' && event.shiftKey);
if (event.key === 'F1' || isQuestionMark) {
event.preventDefault();
setShowShortcuts(true);
if (typeof setShowGlobalShortcuts === 'function') setShowGlobalShortcuts(true);
return;
}
@@ -159,17 +162,57 @@ export const useKeyboardListNavigation = ({
if (event.key === 'End') {
event.preventDefault();
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);
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 {
showShortcuts,
setShowShortcuts,
shortcuts,
showShortcuts: showGlobalShortcuts,
setShowShortcuts: setShowGlobalShortcuts,
shortcuts: finalShortcuts,
};
};

13
ui/src/pages/ADSB.scss Normal file
View 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
View 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;

View File

@@ -136,3 +136,6 @@
transition: all 0.2s ease;
}
}
// APRS-specific styles (briefing styles moved to shared ProtocolBriefing.scss)
// Add any APRS-specific styles here if needed

View File

@@ -15,6 +15,13 @@ const APRS: React.FC<Props> = ({ navLinks = [] }) => {
const viewButtons = (
<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
to="/aprs/packets"
className={`btn d-none d-lg-inline-block ${location.pathname.startsWith('/aprs/packets') ? 'btn-primary' : 'btn-outline-light'}`}

View File

@@ -1,3 +1,5 @@
// MeshCore-specific styles (briefing styles moved to shared ProtocolBriefing.scss)
@keyframes pulse {
0%,
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 {
@@ -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 {
appearance: none;
border: 0;
@@ -384,6 +917,10 @@
}
}
.meshcore-payload-group {
color: #717179;
}
.meshcore-ws-legend {
display: flex;
gap: 0.8rem;
@@ -531,3 +1068,24 @@
box-shadow: 0 0 10px rgba(168, 201, 255, 0.9);
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;
}

View File

@@ -1,6 +1,8 @@
import React from 'react';
import { ButtonGroup } from 'react-bootstrap';
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 StorageIcon from '@mui/icons-material/Storage';
import Layout from '../components/Layout';
@@ -18,6 +20,14 @@ const MeshCore: React.FC<Props> = ({ navLinks = [] }) => {
const viewButtons = (
<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
to="/meshcore/packets"
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" />
<span className="ms-1 d-none d-lg-inline">Chat</span>
</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
to="/meshcore/map"
className={`btn btn-sm btn-icon ${location.pathname.startsWith('/meshcore/map') ? 'btn-primary' : 'btn-outline-light'}`}

View File

@@ -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 {
padding: 8px;
height: 100%;

View File

@@ -1,5 +1,7 @@
import type React from "react";
import { Card } from "react-bootstrap";
import Layout from "../components/Layout";
import Full from "../components/Full";
import RadioCard from "../components/RadioCard";
import { useRadios } from "../contexts/RadiosContext";
import type { NavLinkItem } from "../types/layout.types";
@@ -7,7 +9,7 @@ import type { Radio } from "../types/radio.types";
import './Overview.scss';
interface Props {
navLinks?: NavLinkItem[]
navLinks?: NavLinkItem[];
}
const MAX_RADIOS_PER_ROW = 6;
@@ -45,9 +47,20 @@ export const Overview: React.FC<Props> = ({ navLinks = [] }) => {
return (
<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">
<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>}
@@ -60,7 +73,7 @@ export const Overview: React.FC<Props> = ({ navLinks = [] }) => {
{!loading && !error && radios.length > 0 && (
<>
<div className="overview-category">
<h2 className="overview-category-title">Online ({onlineRadiosCount})</h2>
<h3 className="overview-category-title">Online ({onlineRadiosCount})</h3>
{onlineRows.length === 0 ? (
<div className="overview-empty">No online radios.</div>
) : (
@@ -78,11 +91,9 @@ export const Overview: React.FC<Props> = ({ navLinks = [] }) => {
)}
</div>
{offlineRows.length > 0 && (
<div className="overview-category">
<h2 className="overview-category-title">Offline ({offlineRadiosCount})</h2>
{offlineRows.length === 0 ? (
<div className="overview-empty">No offline radios.</div>
) : (
<h3 className="overview-category-title">Offline ({offlineRadiosCount})</h3>
<div className="overview-radios-rows">
{offlineRows.map((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>
)}
</>
)}
</section>
</div>
</Card>
</Full>
</Layout>
)
}
export default Overview

View 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);

View 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>;
};

View 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='&copy; <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;

View 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;

View File

@@ -1,20 +1,5 @@
import { createContext, useContext } from 'react';
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;
}
import type { Packet } from '../../protocols/meshcore';
export interface MeshCoreGroupChatRecord {
hash: string;
@@ -33,7 +18,7 @@ export interface MeshCoreNodePoint {
}
export interface MeshCoreDataContextValue {
packets: MeshCorePacketRecord[];
packets: Packet[];
groupChats: MeshCoreGroupChatRecord[];
mapPoints: MeshCoreNodePoint[];
streamReady: boolean;

View File

@@ -1,8 +1,8 @@
import React, { useEffect, useMemo, useState } from 'react';
import { bytesToHex } from '@noble/hashes/utils.js';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import BrandingWatermarkIcon from '@mui/icons-material/BrandingWatermark';
import LeakAddIcon from '@mui/icons-material/LeakAdd';
import PersonIcon from '@mui/icons-material/Person';
import QuestionMarkIcon from '@mui/icons-material/QuestionMark';
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 SignalCellularAltIcon from '@mui/icons-material/SignalCellularAlt';
import StorageIcon from '@mui/icons-material/Storage';
import WifiTetheringIcon from '@mui/icons-material/WifiTethering';
import { Packet } from '../../protocols/meshcore';
import { NodeType, PayloadType, RouteType } from '../../types/protocol/meshcore.types';
import { MeshCoreStream } from '../../services/MeshCoreStream';
import type { MeshCoreMessage } from '../../services/MeshCoreStream';
import type { Payload, AdvertPayload } from '../../types/protocol/meshcore.types';
import API from '../../services/API';
import MeshCoreServiceImpl from '../../services/MeshCoreService';
import { base64ToBytes } from '../../util';
import MeshCoreService from '../../services/MeshCoreService';
import MeshCoreStream from '../../services/MeshCoreStream';
import {
MeshCoreDataContext,
type MeshCoreDataContextValue,
type MeshCorePacketRecord,
type MeshCoreGroupChatRecord,
type MeshCoreNodePoint,
} from './MeshCoreContext';
@@ -32,7 +30,6 @@ export {
MeshCoreDataContext,
useMeshCoreData,
type MeshCoreDataContextValue,
type MeshCorePacketRecord,
type MeshCoreGroupChatRecord,
type MeshCoreNodePoint,
} from './MeshCoreContext';
@@ -68,6 +65,21 @@ export const routeTypeNameByValue: Record<number, string> = {
[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(
Object.entries(PayloadType).map(([name, value]) => [name, value])
) as Record<string, number>;
@@ -104,6 +116,7 @@ export const PayloadTypeIcon: React.FC<{ payloadType: number }> = ({ payloadType
return <QuestionMarkIcon className="meshcore-payload-icon" titleAccess="Unknown" />;
}
};
export const nodeTypeValueByName = Object.fromEntries(
Object.entries(NodeType).map(([name, value]) => [name, value])
) as Record<string, number>;
@@ -182,122 +195,59 @@ export const routeValueByUrl: Record<string, number> = Object.fromEntries(
const DISCARD_DUPLICATE_PATH_PACKETS = true;
const getPacketPathKey = (raw: Uint8Array): string => {
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[] => {
const dedupeByHashAndPath = (packets: Packet[]): Packet[] => {
if (!DISCARD_DUPLICATE_PATH_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 deduped: MeshCorePacketRecord[] = [];
const deduped: Packet[] = [];
sortedByReceiveTime.forEach((packet) => {
const signature = `${packet.hash}:${getPacketPathKey(packet.raw)}`;
if (seen.has(signature)) {
const hash = packet.hash();
if (seen.has(hash)) {
return;
}
seen.add(signature);
seen.add(hash);
deduped.push(packet);
});
return deduped;
};
const summarizePayload = (payloadType: number, decodedPayload: Payload | undefined, payloadBytes: Uint8Array): string => {
switch (payloadType) {
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)))}`;
}
};
// `payloadSummary` and its summarization logic were removed — summaries are rendered
// directly in the packet rows now. Keep path/key helpers above.
const toGroupChats = (packets: MeshCorePacketRecord[]): MeshCoreGroupChatRecord[] => {
const toGroupChats = (packets: Packet[]): MeshCoreGroupChatRecord[] => {
return packets
.filter((packet) => packet.payloadType === PayloadType.GROUP_TEXT || packet.payloadType === PayloadType.TEXT)
.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
? (payload.channelHash as string)
: 'general') as string;
const sender = (payload && typeof payload === 'object' && 'srcHash' in payload
? (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 {
hash: packet.hash,
timestamp: packet.timestamp,
channel,
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>();
packets.forEach((packet) => {
@@ -327,57 +277,47 @@ const toMapPoints = (packets: MeshCorePacketRecord[]): MeshCoreNodePoint[] => {
};
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 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(() => {
let isMounted = true;
const fetchPackets = async () => {
try {
const fetchedPackets = await meshCoreService.fetchPackets();
const fetchedPackets = await meshCoreService.fetchPackets(undefined, undefined, undefined, stream.keyManager);
if (!isMounted) {
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) => {
const merged = dedupeByHashAndPath([...records, ...prev]);
const merged = dedupeByHashAndPath([...fetchedPackets, ...prev]);
return merged
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
.sort((a, b) => b.receivedAt.getTime() - a.receivedAt.getTime())
.slice(0, 500);
});
} catch (error) {
@@ -396,7 +336,7 @@ export const MeshCoreDataProvider: React.FC<{ children: React.ReactNode }> = ({
'meshcore/packet/#',
(message) => {
setPackets((prev) => {
const packet: MeshCorePacketRecord = {
const packet: Packet = {
timestamp: message.receivedAt,
hash: message.hash,
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(),
raw: message.raw,
decodedPayload: message.decodedPayload,
payloadSummary: '',
radioName: message.radioName,
snr: message.snr,
rssi: message.rssi,
@@ -423,12 +362,12 @@ export const MeshCoreDataProvider: React.FC<{ children: React.ReactNode }> = ({
const pathLength = message.raw[1] & 0x3f;
packet.path = message.raw.slice(2, 2 + pathLength);
// Summarize payload
const payloadBytes = message.raw.slice(2 + pathLength);
packet.payloadSummary = summarizePayload(packet.payloadType, message.decodedPayload, payloadBytes);
// If the stream provided a decrypted group message, attach it to the record
if (message.decryptedGroup) {
packet.decryptedGroup = message.decryptedGroup;
}
} catch (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]);

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState, useMemo, useRef } from 'react';
import { useSearchParams } from 'react-router';
import { Badge, Card, Stack, Spinner, Alert } from 'react-bootstrap';
import { useMeshCoreData } from './MeshCoreContext';
import VerticalSplit from '../../components/VerticalSplit';
@@ -6,7 +7,7 @@ import StreamStatus from '../../components/StreamStatus';
import MeshCoreServiceImpl, { type MeshCoreGroupRecord } from '../../services/MeshCoreService';
import API from '../../services/API';
import type { MeshCoreGroupChatRecord } from './MeshCoreContext';
import { KeyManager, Packet } from '../../protocols/meshcore';
import { KeyManager, Packet, GroupSecret } from '../../protocols/meshcore';
import KeyboardShortcutsModal from '../../components/KeyboardShortcutsModal';
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-meta">
<small>ID: {group.id}</small>
{group.isPublic && <Badge bg="info" className="ms-2">public</Badge>}
</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="data-table-scroll">
<Stack gap={3} className="p-3">
{messages.map((message) => (
<div key={message.hash + message.timestamp.toISOString()} className="meshcore-message-item">
<Stack gap={1} className="p-3">
{messages.map((message, index) => (
<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">
<strong className="meshcore-message-sender">{message.sender}</strong>
<small className="text-secondary">{message.timestamp.toLocaleTimeString()}</small>
</div>
<div className="meshcore-message-text mb-2">{message.message}</div>
<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>
</div>
</div>
@@ -143,6 +142,7 @@ const GroupMessagesPane: React.FC<{
const MeshCoreGroupChatView: React.FC = () => {
const { groupChats, streamReady } = useMeshCoreData();
const [searchParams, setSearchParams] = useSearchParams();
const [groups, setGroups] = useState<MeshCoreGroupRecord[]>([]);
const [selectedGroup, setSelectedGroup] = useState<MeshCoreGroupRecord | null>(null);
const [isLoadingGroups, setIsLoadingGroups] = useState(false);
@@ -151,6 +151,7 @@ const MeshCoreGroupChatView: React.FC = () => {
const [packetsError, setPacketsError] = useState<string | null>(null);
const [decryptedMessages, setDecryptedMessages] = useState<MeshCoreGroupChatRecord[]>([]);
const keyManagerRef = useRef<KeyManager>(new KeyManager());
const initialChannel = searchParams.get('channel');
// Fetch groups on mount
useEffect(() => {
@@ -159,7 +160,6 @@ const MeshCoreGroupChatView: React.FC = () => {
setGroupsError(null);
try {
const fetchedGroups = await meshCoreService.fetchGroups();
setGroups(fetchedGroups);
// Add groups to key manager
const keyManager = keyManagerRef.current;
@@ -171,8 +171,71 @@ const MeshCoreGroupChatView: React.FC = () => {
}
}
if (fetchedGroups.length > 0 && !selectedGroup) {
setSelectedGroup(fetchedGroups[0]);
// Sort groups: "Public" (case-insensitive) first, then groups
// 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) {
setGroupsError(err instanceof Error ? err.message : 'Failed to load groups');
@@ -205,9 +268,10 @@ const MeshCoreGroupChatView: React.FC = () => {
const payload = p.decode();
if (payload && 'cipherText' in payload && 'cipherMAC' in payload && 'channelHash' in payload) {
try {
const decrypted = keyManagerRef.current.decryptGroup(
packet.channel_hash,
payload.cipherText as Uint8Array,
payload.channelHash,
payload.cipherText,
payload.cipherMAC
);
@@ -218,9 +282,13 @@ const MeshCoreGroupChatView: React.FC = () => {
sender: decrypted.message.split(':')[0] || 'Unknown',
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) {
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
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 unique = combined.filter((msg) => {
const unique = streamMessages.filter((msg) => {
if (seen.has(msg.hash)) return false;
seen.add(msg.hash);
return true;
@@ -258,6 +330,14 @@ const MeshCoreGroupChatView: React.FC = () => {
const handleSelectGroup = (group: MeshCoreGroupRecord) => {
setSelectedGroup(group);
try {
setSearchParams((sp) => {
sp.set('channel', group.name);
return sp;
});
} catch (err) {
// ignore
}
};
return (

View File

@@ -0,0 +1,368 @@
import React, { useEffect, useMemo, useState, useRef } from 'react';
import { Card, Table, Spinner, Alert, Pagination, Form } from 'react-bootstrap';
import API from '../../services/API';
import MeshCoreService from '../../services/MeshCoreService';
import VerticalSplit from '../../components/VerticalSplit';
import { useKeyboardListNavigation } from '../../hooks/useKeyboardListNavigation';
const meshCoreService = new MeshCoreService(API);
export const MeshCoreNodesView: React.FC = () => {
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 [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);
console.debug('MeshCoreNodesView: initial pager', { page, visibleRows, fetchLimit, serverPage, pager: p });
if (!isMounted) return;
// Sort items alphabetically by name
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]);
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));
console.debug('MeshCoreNodesView: background serial load start', { total, pageSize, pages });
// 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++) {
console.debug('MeshCoreNodesView: fetching page', { p, pageSize });
try {
// serial await ensures requests are not concurrent
// eslint-disable-next-line no-await-in-loop
const res = await meshCoreService.fetchNodes(p, pageSize);
if (!isMounted) return;
console.debug('MeshCoreNodesView: fetched page result', { p, items: res.items ? res.items.length : 0 });
if (Array.isArray(res.items)) items.push(...res.items);
} catch (err) {
console.debug('MeshCoreNodesView: fetch page error', { p, error: 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 || ''));
console.debug('MeshCoreNodesView: finished serial background load, total unique items', unique.length);
if (isMounted) setAllNodes(unique);
} catch (err) {
console.debug('MeshCoreNodesView: background loadAll fatal error', err);
} finally {
if (isMounted) setAllLoading(false);
}
};
void loadAllSerial();
return () => { isMounted = false; };
}, [pager, fetchLimit]);
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>
)}
</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">
<thead>
<tr>
<th>Name</th>
<th>Node ID</th>
<th>Type</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>{n.last_heard_at ? new Date(n.last_heard_at).toLocaleString() : '-'}</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> {selectedNode.first_heard_at ? new Date(selectedNode.first_heard_at).toLocaleString() : '-'}</div>
<div><strong>Last seen:</strong> {selectedNode.last_heard_at ? new Date(selectedNode.last_heard_at).toLocaleString() : '-'}</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;

View File

@@ -1,16 +1,17 @@
import React, { useMemo } from 'react';
import React from 'react';
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 type { Segment } from '../../protocols/dissection.types';
import type { Segment } from '../../types/protocol/dissection.types';
import type { MeshCorePacketRecord } from './MeshCoreContext';
import {
payloadNameByValue,
routeDisplayByValue,
} 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 }) => (
<div className="meshcore-fact-row">
@@ -19,19 +20,9 @@ const HeaderFact: React.FC<{ label: string; value: React.ReactNode }> = ({ label
</div>
);
const bitSlice = (value: number, msb: number, lsb: number): number => {
const width = msb - lsb + 1;
const mask = (1 << width) - 1;
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 buildMeshCoreSegments = (packet: Packet): Segment[] => {
const { pathHashSize, pathHashCount } = packet;
const pathLength = pathHashSize * pathHashCount;
const segments: Segment[] = [
{
@@ -75,14 +66,14 @@ const buildMeshCoreSegments = (packet: MeshCorePacketRecord): Segment[] => {
},
];
if (pathBytesAvailable > 0) {
if (pathLength > 0) {
segments.push({
name: 'Path Data',
offset: 2,
byteCount: pathBytesAvailable,
byteCount: pathLength,
attributes: [
{
byteWidth: pathBytesAvailable,
byteWidth: pathLength,
type: 'bytes',
name: `Path Hashes (${pathHashCount} × ${pathHashSize} bytes)`,
},
@@ -90,22 +81,26 @@ const buildMeshCoreSegments = (packet: MeshCorePacketRecord): Segment[] => {
});
}
const payloadLength = packet.raw.length - payloadOffset;
if (payloadLength > 0) {
const payloadOffset = 2 + (packet.transportCodes ? packet.transportCodes.length * 2 : 0) + pathLength;
if (packet.payload.length > 0) {
segments.push({
name: 'Payload',
offset: payloadOffset,
byteCount: payloadLength,
byteCount: packet.payload.length,
attributes: [
{
byteWidth: payloadLength,
byteWidth: packet.payload.length,
type: 'bytes',
name: `Payload Data (${payloadLength} bytes)`,
name: `Payload Data (${packet.payload.length} bytes)`,
},
],
});
}
if (packet.segments) {
segments.push(...packet.segments);
}
return segments;
};
@@ -116,24 +111,24 @@ const asRecord = (value: unknown): Record<string, unknown> | null => {
return null;
};
const PayloadDetails: React.FC<{ packet: MeshCorePacketRecord }> = ({ packet }) => {
const payload = packet.decodedPayload;
const PayloadDetails: React.FC<{ packet: Packet }> = ({ packet }) => {
const payload = packet.decodedPayload as Payload | undefined;
const payloadObj = asRecord(payload);
if (!payloadObj) {
if (typeof payload === 'undefined' || !payload || !payloadObj) {
return (
<Card body className="data-table-card">
<h6 className="mb-2">Payload</h6>
<div>Unable to decode payload; showing raw bytes only.</div>
<code>{bytesToHex(packet.raw)}</code>
<code>{bytesToHex(packet.payload)}</code>
</Card>
);
}
if (typeof payloadObj.flags === 'number' && payloadObj.data instanceof Uint8Array) {
if (typeof payloadObj?.flags === 'number' && payloadObj?.data instanceof Uint8Array) {
return (
<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="Data Length" value={payloadObj.data.length} />
<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 (
typeof payloadObj.channelHash === 'string'
&& payloadObj.cipherText instanceof Uint8Array
@@ -148,9 +155,9 @@ const PayloadDetails: React.FC<{ packet: MeshCorePacketRecord }> = ({ packet })
) {
return (
<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="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>} />
</Card>
);
@@ -164,9 +171,9 @@ const PayloadDetails: React.FC<{ packet: MeshCorePacketRecord }> = ({ packet })
) {
return (
<Card body className="data-table-card">
<h6 className="mb-2">Encrypted Payload</h6>
<HeaderFact label="Destination" value={payloadObj.dstHash} />
<HeaderFact label="Source" value={payloadObj.srcHash} />
<h6 className="mb-2">{payloadNameByValue[packet.payloadType] ?? packet.payloadType} (Encrypted)</h6>
<HeaderFact label="Destination" value={<span className="meshcore-hash">{payloadObj.dstHash}</span>} />
<HeaderFact label="Source" value={<span className="meshcore-hash">{payloadObj.srcHash}</span>} />
<HeaderFact label="Cipher Text Length" value={payloadObj.cipherText.length} />
<HeaderFact label="Cipher MAC" value={<code>{bytesToHex(payloadObj.cipherMAC)}</code>} />
</Card>
@@ -176,13 +183,47 @@ const PayloadDetails: React.FC<{ packet: MeshCorePacketRecord }> = ({ packet })
if (payloadObj.data instanceof Uint8Array) {
return (
<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" value={<code>{bytesToHex(payloadObj.data)}</code>} />
</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 (
<Card body className="data-table-card">
<h6 className="mb-2">Payload</h6>
@@ -192,7 +233,7 @@ const PayloadDetails: React.FC<{ packet: MeshCorePacketRecord }> = ({ packet })
};
interface MeshCorePacketDetailsPaneProps {
packet: MeshCorePacketRecord | null;
packet: Packet | null;
streamReady: boolean;
}
@@ -209,6 +250,7 @@ const MeshCorePacketDetailsPane: React.FC<MeshCorePacketDetailsPaneProps> = ({ p
return (
<Stack gap={2} className="h-100 meshcore-detail-stack">
{/*
<Card body className="data-table-card">
<Stack direction="horizontal" gap={2} className="mb-2">
<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="Raw Packet" value={<code>{bytesToHex(packet.raw)}</code>} />
</Card>
*/}
<PayloadDetails packet={packet} />
<Card body className="data-table-card">
<PacketDissectionViewer
rawPacket={packet.raw}
segments={buildMeshCoreSegments(packet)}
title="Packet Bytes (Wire View)"
rawPacket={packet.toBytes()}
segments={packet.segments || buildMeshCoreSegments(packet)}
title={`${payloadNameByValue[packet.payloadType] ?? packet.payloadType} Packet Dissection`}
/>
</Card>
<PayloadDetails packet={packet} />
<Card body className="data-table-card">
<h6 className="mb-2">Stream Preparation</h6>
<div>MeshCore stream service is initialized and ready for topic subscriptions.</div>

View File

@@ -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 ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
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 type { MeshCorePacketRecord } from './MeshCoreContext';
import { Packet } from '../../protocols/meshcore';
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 {
payloadNameByValue,
PayloadTypeIcon,
@@ -29,22 +55,17 @@ const getPayloadTypeColor = (payloadType: number): string => {
}
};
const getPathInfo = (packet: MeshCorePacketRecord): { prefixes: string; hopCount: number } => {
if (packet.raw.length < 2) {
return { prefixes: 'none', hopCount: 0 };
}
const getPathInfo = (packet: Packet): { prefixes: string; hopCount: number } => {
const hashCount = packet.getPathHashCount();
const hashSize = packet.getPathHashSize();
const pathField = packet.raw[1];
const hashSize = (pathField >> 6) + 1;
const hashCount = pathField & 0x3f;
if (hashCount === 0 || hashSize === 4) {
if (!packet.path || hashCount === 0 || hashSize === 4) {
return { prefixes: 'none', hopCount: 0 };
}
const pathByteLength = hashCount * hashSize;
const availablePathBytes = Math.min(pathByteLength, Math.max(packet.raw.length - 2, 0));
const pathBytes = packet.raw.slice(2, 2 + availablePathBytes);
const availablePathBytes = Math.min(pathByteLength, Math.max(packet.path.length, 0));
const pathBytes = packet.path.slice(0, availablePathBytes);
if (pathBytes.length === 0) {
return { prefixes: 'none', hopCount: 0 };
@@ -67,15 +88,15 @@ const getPathInfo = (packet: MeshCorePacketRecord): { prefixes: string; hopCount
export interface MeshCorePacketGroup {
hash: string;
packets: MeshCorePacketRecord[];
mostRecent: MeshCorePacketRecord;
packets: Packet[];
mostRecent: Packet;
}
interface MeshCorePacketRowsProps {
groupedPackets: MeshCorePacketGroup[];
expandedHashes: Set<string>;
onToggleExpanded: (hash: string) => void;
onSelect: (packet: MeshCorePacketRecord) => void;
onSelect: (packet: Packet) => void;
selectedHash: string | null;
}
@@ -86,6 +107,234 @@ const MeshCorePacketRows: React.FC<MeshCorePacketRowsProps> = ({
onSelect,
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 (
<>
{groupedPackets.map((group) => {
@@ -98,14 +347,14 @@ const MeshCorePacketRows: React.FC<MeshCorePacketRowsProps> = ({
if (pathA !== pathB) {
return pathA - pathB;
}
return b.timestamp.getTime() - a.timestamp.getTime();
return b.receivedAt.getTime() - a.receivedAt.getTime();
});
return (
<React.Fragment key={group.hash}>
<tr
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)}
>
<td>
@@ -123,42 +372,49 @@ const MeshCorePacketRows: React.FC<MeshCorePacketRowsProps> = ({
</button>
)}
</td>
<td>
{packet.timestamp.toLocaleTimeString()}
<td className="font-monospace">
{packet.receivedAt.toLocaleTimeString()}
{hasDuplicates && (
<span className="meshcore-duplicate-badge" title={`${group.packets.length} instances`}>
{' '}×{group.packets.length}
</span>
)}
</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>
<button type="button" className="meshcore-hash-button" onClick={() => onSelect(packet)}>
{packet.hash}
</button>
<SignalGrade snr={packet.snr} rssi={(packet as any).rssi} />
</td>
<td>
<div className="meshcore-hash">
{packet.hash()}
</div>
</td>
<td className="meshcore-payload-type-cell" title={payloadNameByValue[packet.payloadType] ?? `0x${packet.payloadType.toString(16)}`}>
<PayloadTypeIcon payloadType={packet.payloadType} />
</td>
<td>{packet.payloadSummary}</td>
<td>{renderPayloadSummary(packet)}</td>
</tr>
{isExpanded && expandedPackets.map((duplicatePacket, index) => (
<tr
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)}
>
<td></td>
<td>{duplicatePacket.timestamp.toLocaleTimeString()}</td>
<td>{duplicatePacket.receivedAt.toLocaleTimeString()}</td>
<td>
<button type="button" className="meshcore-hash-button" onClick={() => onSelect(duplicatePacket)}>
{duplicatePacket.hash}
{duplicatePacket.hash()}
</button>
</td>
<td className="meshcore-payload-type-cell" title={payloadNameByValue[duplicatePacket.payloadType] ?? `0x${duplicatePacket.payloadType.toString(16)}`}>
<PayloadTypeIcon payloadType={duplicatePacket.payloadType} />
</td>
<td>{duplicatePacket.snr !== undefined ? duplicatePacket.snr.toFixed(1) : '-'} dB</td>
<td>
<SignalGrade snr={duplicatePacket.snr} rssi={(duplicatePacket as any).rssi} />
</td>
<td>
{getPathInfo(duplicatePacket).prefixes}
</td>

View File

@@ -9,7 +9,6 @@ import StreamStatus from '../../components/StreamStatus';
import KeyboardShortcutsModal from '../../components/KeyboardShortcutsModal';
import { useKeyboardListNavigation } from '../../hooks/useKeyboardListNavigation';
import { useRadiosByProtocol } from '../../contexts/RadiosContext';
import type { MeshCorePacketRecord } from './MeshCoreContext';
import {
payloadUrlByValue,
payloadValueByUrl,
@@ -18,11 +17,12 @@ import {
} from './MeshCoreData';
import MeshCorePacketFilters from './MeshCorePacketFilters';
import MeshCorePacketRows, { type MeshCorePacketGroup } from './MeshCorePacketRows';
import type { Packet } from '../../protocols/meshcore';
interface MeshCorePacketTableProps {
packets: MeshCorePacketRecord[];
packets: Packet[];
selectedHash: string | null;
onSelect: (packet: MeshCorePacketRecord) => void;
onSelect: (packet: Packet) => void;
onClearSelection: () => void;
streamReady: boolean;
}
@@ -94,30 +94,34 @@ const MeshCorePacketTable: React.FC<MeshCorePacketTableProps> = ({ packets, sele
}, [packets, radios]);
const groupedPackets = useMemo((): MeshCorePacketGroup[] => {
const groups = new Map<string, MeshCorePacketRecord[]>();
const groups = new Map<string, Packet[]>();
packets.forEach((packet) => {
if (filterPayloadTypes.size > 0 && !filterPayloadTypes.has(packet.payloadType)) return;
if (filterRouteTypes.size > 0 && !filterRouteTypes.has(packet.routeType)) return;
if (filterRadios.size > 0 && packet.radioName && !filterRadios.has(packet.radioName)) return;
for (const packet of packets) {
if (filterPayloadTypes.size > 0 && !filterPayloadTypes.has(packet.payloadType)) continue;
if (filterRouteTypes.size > 0 && !filterRouteTypes.has(packet.routeType)) continue;
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) {
existing.push(packet);
} else {
groups.set(packet.hash, [packet]);
groups.set(hash, [packet]);
}
}
});
return Array.from(groups.entries())
.map(([hash, grouped]) => ({
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) => (
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]);
const handlePayloadTypeToggle = (value: number, isChecked: boolean) => {
@@ -165,7 +169,7 @@ const MeshCorePacketTable: React.FC<MeshCorePacketTableProps> = ({ packets, sele
if (!selectedHash) {
return null;
}
const index = navigablePackets.findIndex((packet) => packet.hash === selectedHash);
const index = navigablePackets.findIndex((packet) => packet.hash() === selectedHash);
return index >= 0 ? index : null;
}, [navigablePackets, selectedHash]);
@@ -212,11 +216,12 @@ const MeshCorePacketTable: React.FC<MeshCorePacketTableProps> = ({ packets, sele
<Table hover responsive className="data-table mb-0" size="sm">
<thead>
<tr>
<th style={{ width: '30px' }}></th>
<th style={{ width: '100px' }}>Time</th>
<th style={{ width: '60px' }}>SNR</th>
<th style={{ width: '80px' }}>Hash</th>
<th style={{ width: '50px' }}>Type</th>
<th style={{ width: '2ch' }}></th>
<th style={{ width: '7ch' }}>Time</th>
<th style={{ width: '4ch' }}>SNR</th>
<th style={{ width: '9ch' }}>Quality</th>
<th style={{ width: '12ch' }}>Hash</th>
<th style={{ width: '6ch' }}>Type</th>
<th>Info</th>
</tr>
</thead>

View File

@@ -13,7 +13,7 @@ const MeshCorePacketsView: React.FC = () => {
if (!selectedHash) {
return null;
}
return packets.find((packet) => packet.hash === selectedHash) ?? null;
return packets.find((packet) => packet.hash() === selectedHash) ?? null;
}, [packets, selectedHash]);
return (
@@ -23,7 +23,7 @@ const MeshCorePacketsView: React.FC = () => {
<MeshCorePacketTable
packets={packets}
selectedHash={selectedHash}
onSelect={(packet) => setSelectedHash(packet.hash)}
onSelect={(packet) => setSelectedHash(packet.hash())}
onClearSelection={() => setSelectedHash(null)}
streamReady={streamReady}
/>

View 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;

View 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
View 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;

View File

@@ -740,7 +740,9 @@ export class Frame implements IFrame {
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) {
return { payload: null };
}

View File

@@ -1,5 +1,5 @@
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 type {
@@ -174,6 +174,40 @@ describe('GroupSecret', () => {
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!');
});
});
});
});

View File

@@ -16,7 +16,6 @@ import type {
BaseSharedSecret,
ControlPayload,
DecryptedGroupMessage,
Group,
GroupDataPayload,
GroupSecretValue,
GroupTextPayload,
@@ -30,6 +29,7 @@ import type {
ResponsePayload,
TextPayload,
TracePayload,
DecryptedTextMessage,
} from '../types/protocol/meshcore.types';
// Local imports
@@ -38,9 +38,12 @@ import {
AdvertisementFlags,
BaseGroupSecret,
BasePacket,
payloadNameByValue,
PayloadType,
RouteType,
} from '../types/protocol/meshcore.types';
import type { PacketMeta } from '../types/protocol.types';
import { routeTypeNameByValue } from '../pages/meshcore/MeshCoreData';
const MAX_PATH_SIZE = 64;
@@ -56,50 +59,76 @@ export const hasTransportCodes = (routeType: RouteType): boolean => {
return routeType === RouteType.TRANSPORT_FLOOD || routeType === RouteType.TRANSPORT_DIRECT;
}
export class Packet extends BasePacket {
private _frameworkSection?: Segment;
export class Packet extends BasePacket implements PacketMeta {
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) {
super();
constructor(
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 === 'string') {
data = base64ToBytes(data);
}
this.parse(data, emitSections);
}
}
public parse(data: Uint8Array, emitSections = false) {
let offset = 0;
const frameworkChildren: Segment[] = [];
const segments: Segment[] = [];
const header = data[0];
this.routeType = (header >> 0) & 0x03;
this.payloadType = (header >> 2) & 0x0F;
this.version = (header >> 6) & 0x03;
const version = (header >> 6) & 0x03;
const routeType = header & 0x03;
const payloadType = (header >> 2) & 0x0f;
// Build header section
if (emitSections) {
frameworkChildren.push({
segments.push({
name: 'Header',
offset,
byteCount: 1,
attributes: [
{ byteWidth: 1, type: 'uint8', name: 'Header Byte' },
bitfields: [
{ 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++;
let index = 1;
if (hasTransportCodes(this.routeType)) {
let transportCodes: Uint16Array | undefined;
if (hasTransportCodes(routeType)) {
const view = new DataView(data.buffer, index, index + 4);
this.transportCodes = new Uint16Array(2);
this.transportCodes[0] = view.getUint16(0, true);
this.transportCodes[1] = view.getUint16(2, true);
transportCodes = new Uint16Array(2);
transportCodes[0] = view.getUint16(0, true);
transportCodes[1] = view.getUint16(2, true);
if (emitSections) {
frameworkChildren.push({
segments.push({
name: 'Transport Codes',
offset,
byteCount: 4,
@@ -113,39 +142,36 @@ export class Packet extends BasePacket {
offset += 4;
}
this.pathLength = data[index];
if (emitSections) {
frameworkChildren.push({
name: 'Path Length',
offset,
byteCount: 1,
attributes: [
{ byteWidth: 1, type: 'uint8', name: 'Path Length' },
],
});
}
const pathLength = data[index];
index++;
offset++;
if (!this.isValidPathLength()) {
throw new Error(`MeshCore: invalid path length ${this.pathLength}`)
if (!this.isValidPathLength(pathLength)) {
throw new Error(`MeshCore: invalid path length ${pathLength}`)
}
const pathBytesLength = this.getPathBytesLength();
this.path = new Uint8Array(pathBytesLength);
const pathHashSize = (pathLength >> 6) + 1;
const pathHashCount = pathLength & 0x3f;
const pathBytesLength = pathHashSize * pathHashCount;
const path = new Uint8Array(pathBytesLength);
for (let i = 0; i < pathBytesLength; i++) {
this.path[i] = data[index];
path[i] = data[index];
index++;
}
if (emitSections) {
frameworkChildren.push({
segments.push({
name: 'Path',
offset,
offset: offset - 1,
byteCount: pathBytesLength,
attributes: [
{ byteWidth: 1, type: 'uint8', name: 'Path Length' },
{ 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;
@@ -154,123 +180,117 @@ export class Packet extends BasePacket {
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;
this.payload = new Uint8Array(payloadBytesLength);
const payload = new Uint8Array(payloadBytesLength);
for (let i = 0; i < payloadBytesLength; i++) {
this.payload[i] = data[index];
payload[i] = data[index];
index++;
}
if (emitSections) {
segments.push({
name: 'Payload',
offset,
byteCount: payloadBytesLength,
attributes: [
{ byteWidth: payloadBytesLength, type: 'uint8', name: 'Payload Bytes' },
],
});
}
public parseBase64(data: string) {
return this.parse(base64ToBytes(data));
const packet = new Packet(header, transportCodes, pathLength, path, payload, info);
if (emitSections) {
packet.segments = segments;
packet.decodedPayload = packet.decode(true);
}
public getFrameworkSection(): Segment | undefined {
return this._frameworkSection;
return packet;
}
private isValidPathLength(): boolean {
const hashCount = this.getPathHashCount();
const hashSize = this.getPathHashSize();
public static isValidPathLength(pathLength: number): boolean {
const hashCount = pathLength & 0x3F;
const hashSize = (pathLength >> 6) + 1;
if (hashSize === 4) return false; // reserved
return hashCount * hashSize <= MAX_PATH_SIZE;
}
public getPathHashSize(): number {
return (this.pathLength >> 6) + 1;
}
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) {
public hash(): string {
const payloadType = this.getPayloadType();
let data = new Uint8Array([payloadType]);
if (payloadType === PayloadType.TRACE) {
data = new Uint8Array([...data, ...this.path]);
}
data = new Uint8Array([...data, ...this.payload]);
const hash = sha256.create().update(data).digest();
return hash.slice(0, 8);
return bytesToHex(hash.slice(0, 8));
}
public decode(): Payload;
public decode(emitSections: true): { payload: Payload; sections?: Segment[] };
public decode(emitSections = false): Payload | { payload: Payload; sections?: Segment[] } {
let decodedPayload: Payload;
let payloadSections: Segment[] | undefined;
public toBytes(): Uint8Array {
const headerBytes = new Uint8Array([this.header]);
const transportBytes = this.transportCodes ? new Uint8Array(this.transportCodes.buffer) : new Uint8Array();
const pathLengthByte = new Uint8Array([this.pathLength]);
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:
({ payload: decodedPayload, sections: payloadSections } = this.decodeRequest(emitSections));
({ payload, sections } = this.decodeRequest(emitSections));
break;
case PayloadType.RESPONSE:
({ payload: decodedPayload, sections: payloadSections } = this.decodeResponse(emitSections));
({ payload, sections } = this.decodeResponse(emitSections));
break;
case PayloadType.TEXT:
({ payload: decodedPayload, sections: payloadSections } = this.decodeText(emitSections));
({ payload, sections } = this.decodeText(emitSections));
break;
case PayloadType.ACK:
({ payload: decodedPayload, sections: payloadSections } = this.decodeAck(emitSections));
({ payload, sections } = this.decodeAck(emitSections));
break;
case PayloadType.ADVERT:
({ payload: decodedPayload, sections: payloadSections } = this.decodeAdvert(emitSections));
({ payload, sections } = this.decodeAdvert(emitSections));
break;
case PayloadType.GROUP_TEXT:
({ payload: decodedPayload, sections: payloadSections } = this.decodeGroupText(emitSections));
({ payload, sections } = this.decodeGroupText(emitSections));
break;
case PayloadType.GROUP_DATA:
({ payload: decodedPayload, sections: payloadSections } = this.decodeGroupData(emitSections));
({ payload, sections } = this.decodeGroupData(emitSections));
break;
case PayloadType.ANON_REQ:
({ payload: decodedPayload, sections: payloadSections } = this.decodeAnonReq(emitSections));
({ payload, sections } = this.decodeAnonReq(emitSections));
break;
case PayloadType.PATH:
({ payload: decodedPayload, sections: payloadSections } = this.decodePath(emitSections));
({ payload, sections } = this.decodePath(emitSections));
break;
case PayloadType.TRACE:
({ payload: decodedPayload, sections: payloadSections } = this.decodeTrace(emitSections));
({ payload, sections } = this.decodeTrace(emitSections));
break;
case PayloadType.MULTIPART:
({ payload: decodedPayload, sections: payloadSections } = this.decodeMultipart(emitSections));
({ payload, sections } = this.decodeMultipart(emitSections));
break;
case PayloadType.CONTROL:
({ payload: decodedPayload, sections: payloadSections } = this.decodeControl(emitSections));
({ payload, sections } = this.decodeControl(emitSections));
break;
case PayloadType.RAW_CUSTOM:
({ payload: decodedPayload, sections: payloadSections } = this.decodeRawCustom(emitSections));
({ payload, sections } = this.decodeRawCustom(emitSections));
break;
default:
throw new Error(`MeshCore: can't decode payload ${this.payloadType}`)
throw new Error(`MeshCore: can't decode payload ${payloadType}`)
}
if (!emitSections) {
return decodedPayload;
if (emitSections) {
this.segments = this.segments ? [...this.segments, ...(sections ?? [])] : sections;
}
const sections: Segment[] = [];
if (this._frameworkSection) {
sections.push(this._frameworkSection);
}
if (payloadSections) {
sections.push(...payloadSections);
}
return { payload: decodedPayload, sections: sections.length > 0 ? sections : undefined };
return payload;
}
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[] } {
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[] } {
@@ -343,7 +386,14 @@ export class Packet extends BasePacket {
const flags = buffer.readUint8();
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,
};
@@ -374,7 +424,7 @@ export class Packet extends BasePacket {
if (emitSections) {
sections.push({
name: 'Payload',
name: 'Advert Payload',
offset,
byteCount: this.payload.length,
attributes,
@@ -385,7 +435,7 @@ export class Packet extends BasePacket {
payload: {
payloadType: PayloadType.ADVERT,
publicKey,
timestamp: timestampValue === 0 ? undefined : new Date(timestampValue),
timestamp: timestampValue === 0 ? undefined : new Date(timestampValue * 1000),
signature,
appdata,
},
@@ -476,7 +526,10 @@ export class Packet extends BasePacket {
private decodeTrace(emitSections = false): { payload: TracePayload; sections?: Segment[] } {
const sections: Segment[] = [];
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) {
sections.push({
@@ -484,7 +537,9 @@ export class Packet extends BasePacket {
offset: 0,
byteCount: this.payload.length,
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 {
payload: {
payloadType: PayloadType.TRACE,
data,
tag,
authCode,
nodes,
},
sections: emitSections ? sections : undefined,
};
@@ -679,7 +736,7 @@ export class SharedSecret implements BaseSharedSecret {
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);
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 {
private bytes: Uint8Array;
@@ -741,23 +828,24 @@ export class GroupSecret implements BaseGroupSecret {
}
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 {
const ourMAC = hmac(sha256, this.bytes, cipherText)
const ourMAC = hmac(sha256, this.bytes, cipherText).slice(0, 2)
if (!constantTimeEqual(cipherMAC, ourMAC)) {
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);
if (plain.length < 5) {
throw new Error('invalid payload');
}
const reader = new BufferReader(plain);
const timestamp = new Date(reader.readUint32LE());
const timestamp = new Date(reader.readUint32LE() * 1000);
const flags = reader.readUint8();
let message = new TextDecoder('utf-8').decode(reader.readBytes());
const nullPos = message.indexOf('\0')
@@ -774,7 +862,7 @@ export class GroupSecret implements BaseGroupSecret {
static fromName(name: string): GroupSecret {
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) {
let group: Group;
if (secret) {
group = {
name: name,
secret: new GroupSecret(secret)
}
group = new Group(name, new GroupSecret(secret));
} else {
group = {
name: name,
secret: GroupSecret.fromName(name)
group = new Group(name);
}
}
const hash = group.secret.toHash();
const hash = group.toHash();
this.groups.set(hash, [...this.groups.get(hash) || [], group]);
}
@@ -813,7 +895,7 @@ export class KeyManager {
for (const group of this.groups.get(channelHash) || []) {
try {
return {
...group.secret.decrypt(cipherText, cipherMAC),
...group.decrypt(cipherText, cipherMAC),
group: group.name
}
} catch (e) {

View 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;

View File

@@ -1,6 +1,13 @@
import axios, { type AxiosRequestConfig } from 'axios';
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 {
private readonly client;
@@ -18,6 +25,13 @@ export class APIService {
return response.data as T;
}
public async fetchPaginated<T>(endpoint: string, page: number = 1, limit: number = 20): Promise<Pager<T>> {
const response = await this.client.get<Pager<T>>(endpoint, {
params: { page, limit },
});
return response.data as Pager<T>;
}
public async fetchRadios(protocol?: string): Promise<Radio[]> {
const endpoint = protocol ? `/radios/${encodeURIComponent(protocol)}` : '/radios';
return this.fetch<Radio[]>(endpoint);

View File

@@ -1,22 +1,16 @@
import type { APIService } from './API';
import type { Group } from '../types/protocol/meshcore.types';
import type { Packet } from '../types/protocol.types';
import type { APIService, Pager } from './API';
import type { GroupTextPayload, Payload } from '../types/protocol/meshcore.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 { GroupSecret } from '../protocols/meshcore';
import { PacketInfo } from '../types/protocol.types';
interface FetchedMeshCoreGroup {
id: number;
name: string;
secret: string;
isPublic: boolean;
}
// OLD
export type MeshCoreGroupRecord = Group & {
id: number;
isPublic: boolean;
};
export interface FetchedMeshCorePacket extends Packet {
export interface FetchedMeshCorePacket {
id: number;
radio_id: number;
version: number;
@@ -28,9 +22,85 @@ export interface FetchedMeshCorePacket extends Packet {
raw: string;
channel_hash: 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;
constructor(api: APIService) {
@@ -41,16 +111,9 @@ export class MeshCoreServiceImpl {
* Fetch all available MeshCore groups
* @returns Array of Group objects with metadata
*/
public async fetchGroups(): Promise<MeshCoreGroupRecord[]> {
const groups = await this.api.fetch<FetchedMeshCoreGroup[]>('/meshcore/groups');
return groups.map((group) => ({
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,
}));
public async fetchGroups(): Promise<Group[]> {
const groups = await this.api.fetch<FetchedGroup[]>('/meshcore/groups');
return groups.map((group) => new Group(group.name, group.secret, group.isPublic));
}
/**
@@ -64,7 +127,8 @@ export class MeshCoreServiceImpl {
limit = 200,
type?: number,
channelHash?: string,
): Promise<FetchedMeshCorePacket[]> {
keyManager?: KeyManager,
): Promise<Packet[]> {
const endpoint = '/meshcore/packets';
const params: Record<string, unknown> = { limit };
if (type !== undefined) {
@@ -73,7 +137,46 @@ export class MeshCoreServiceImpl {
if (channelHash !== undefined) {
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,45 @@ export class MeshCoreServiceImpl {
* @param channelHash The channel hash to fetch packets for
* @returns Array of raw packet data
*/
public async fetchGroupPackets(channelHash: string): Promise<FetchedMeshCorePacket[]> {
return this.fetchPackets(200, PayloadType.GROUP_TEXT, channelHash);
public async fetchGroupPackets(channelHash: string, keyManager?: KeyManager): Promise<Packet[]> {
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): Promise<Pager<FetchedNode>> {
return this.api.fetchPaginated<FetchedNode>('/meshcore/nodes', page, limit);
}
public async fetchNodesCloseTo(hash: string): Promise<FetchedNodesCloseTo> {
return this.api.fetch<FetchedNodesCloseTo>(`/meshcore/nodes/close-to/${encodeURIComponent(hash)}`);
}
}
export default MeshCoreService;

View File

@@ -1,55 +1,29 @@
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
import { Packet as MeshCorePacket } from '../protocols/meshcore';
import type { Payload } from '../types/protocol/meshcore.types';
import type { Packet } from '../types/protocol.types';
import { Packet, KeyManager } from '../protocols/meshcore';
import { PacketInfo } from '../types/protocol.types';
import { BaseStream } from './Stream';
import { base64ToBytes } from '../util';
export interface MeshCoreMessage extends Packet {
topic: string;
raw: Uint8Array;
hash: string;
decodedPayload?: Payload;
}
interface MeshCoreJsonEnvelope {
payloadBase64?: string;
payloadHex?: string;
interface StreamPacket {
time: string;
raw: string;
snr?: number;
rssi?: number;
}
export class MeshCoreStream extends BaseStream {
public keyManager: KeyManager = new KeyManager();
constructor(autoConnect = false) {
super({}, autoConnect);
}
protected decodeMessage(topic: string, payload: Uint8Array): MeshCoreMessage {
const { bytes: packetBytes, snr, rssi } = this.extractPacketBytes(payload);
const parsed = new MeshCorePacket();
parsed.parse(packetBytes);
let decodedPayload: Payload | undefined;
try {
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,
};
protected decodeMessage(topic: string, payload: Uint8Array): Packet {
const { bytes: packetBytes, time, snr, rssi } = this.extractPacketBytes(payload);
return Packet.fromBytes(packetBytes, new PacketInfo({
receivedAt: time || new Date(),
snr: snr,
rssi: rssi,
radioName: this.extractRadioNameFromTopic(topic),
}));
}
private extractRadioNameFromTopic(topic: string): string | undefined {
@@ -67,25 +41,31 @@ export class MeshCoreStream extends BaseStream {
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();
if (!text.startsWith('{')) {
return { bytes: payload };
}
const envelope = JSON.parse(text) as MeshCoreJsonEnvelope;
const envelope = JSON.parse(text) as StreamPacket;
let bytes: Uint8Array = payload;
let time: Date | undefined = undefined;
if (envelope.payloadBase64) {
bytes = Uint8Array.from(atob(envelope.payloadBase64), (c) => c.charCodeAt(0));
} else if (envelope.payloadHex) {
bytes = hexToBytes(envelope.payloadHex);
if (envelope.raw) {
bytes = base64ToBytes(envelope.raw);
}
if (envelope.time) {
time = new Date(envelope.time);
}
return {
bytes,
time,
snr: envelope.snr,
rssi: envelope.rssi,
};
}
}
export default MeshCoreStream;

View File

@@ -2,9 +2,9 @@ import mqtt from "mqtt";
import type { StreamConnectionOptions, StreamState, TopicSubscription } from "../types/stream.types";
const defaultConnectionOptions: StreamConnectionOptions = {
url: import.meta.env.DEV
? 'ws://10.42.23.73:8083'
: ((window.location.protocol === 'http:') ? 'ws:' : 'wss:') + '//' + window.location.host + '/broker'
url: import.meta.env.DEV ?
'wss://pd0mz.hamnet.nl/broker' :
((window.location.protocol === 'http:') ? 'ws:' : 'wss:') + '//' + window.location.host + '/broker'
}
export abstract class BaseStream {
@@ -12,7 +12,7 @@ export abstract class BaseStream {
protected connectionOptions: StreamConnectionOptions;
protected subscribers: Map<string, Set<(data: any, topic: string) => void>> = new Map();
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 state: StreamState = {
@@ -48,7 +48,9 @@ export abstract class BaseStream {
try {
const randomId = Math.random().toString(16).slice(2, 10);
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.connectionOptions.options,

View 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);
}
}

View File

@@ -2,11 +2,13 @@
THEME STYLES - Reusable Typography & Colors
============================================ */
@import './theme/typography';
@import './theme/buttons';
@import './theme/badges';
@import './theme/tags';
@import './theme/forms';
@import './theme/code';
@import './theme/tables';
@import './theme/utilities';
@use './theme/typography' as *;
@use './theme/buttons' as *;
@use './theme/badges' as *;
@use './theme/tags' as *;
@use './theme/forms' as *;
@use './theme/code' as *;
@use './theme/tables' as *;
@use './theme/utilities' as *;
@use './theme/charts' as *;
@use './theme/bootstrap-overrides' as *;

View File

@@ -0,0 +1,69 @@
// 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;
}

View 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));
}

View File

@@ -32,42 +32,5 @@
}
.data-table {
color: var(--app-text);
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);
}
/* Color and background overrides removed to use default/inherited styles */
}

View File

@@ -20,7 +20,7 @@ export interface FullProps {
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 {
left?: React.ReactNode;

View File

@@ -33,7 +33,7 @@ export const toModulationDisplayName = (modulation: string): string => {
* Base packet interface with common fields from all protocol types.
* These fields are typically extracted from Go's protocol.Packet struct.
*/
export interface Packet {
export interface PacketMeta {
/** When the packet was received */
receivedAt: Date;
/** Signal-to-Noise Ratio in dB */
@@ -43,3 +43,17 @@ export interface Packet {
/** Name/ID of the radio that received the packet */
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;
}
}

View 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
}

View File

@@ -1,4 +1,6 @@
import { bytesToHex } from '@noble/hashes/utils.js';
import type { Segment } from './dissection.types';
import { type PacketMeta, PacketInfo } from '../protocol.types';
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 const routeDisplayByValue: Record<number, string> = {
[RouteType.TRANSPORT_FLOOD]: 'Flood (T)',
[RouteType.FLOOD]: 'Flood',
[RouteType.DIRECT]: 'Direct',
[RouteType.TRANSPORT_DIRECT]: 'Direct (T)',
};
export const PayloadType = {
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)
@@ -30,6 +39,22 @@ export const PayloadType = {
export type PayloadTypeValue = typeof PayloadType[keyof typeof PayloadType];
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 {
version: number;
transportCodes?: Uint16Array;
@@ -40,19 +65,66 @@ export interface Packet {
sections?: Segment[];
}
export abstract class BasePacket {
protected version: number = 1;
protected transportCodes?: Uint16Array;
protected routeType: number = 0;
protected payloadType: number = 0;
protected pathLength: number = 0;
protected path: Uint8Array = new Uint8Array();
protected payload: Uint8Array = new Uint8Array();
export abstract class BasePacket extends PacketInfo {
public header: number;
public transportCodes?: Uint16Array;
public pathLength: number;
public path: Uint8Array;
public payload: Uint8Array;
abstract getPathHashSize(): number;
abstract getPathHashCount(): number;
abstract hash(): Uint8Array;
abstract decode(): Payload;
constructor(
header: number,
transportCodes: Uint16Array | undefined,
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 =
@@ -173,10 +245,9 @@ export interface SignedTextMessage extends DecryptedTextMessage {
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;
dstHash: NodeHash;
srcHash: NodeHash;
checksum: Uint8Array; // 4 bytes, LE
sections?: Segment[];
}
@@ -297,8 +368,9 @@ export interface DecryptedPath {
export interface TracePayload {
readonly payloadType: typeof PayloadType.TRACE;
// Format not fully specified in docs - collecting SNI for each hop
data: Uint8Array;
tag: number; // 4 bytes, LE - used to correlate trace requests and responses
authCode: Uint8Array; // 4 bytes - HMAC or similar to prevent spoofing
nodes: Uint8Array;
sections?: Segment[];
}
@@ -354,11 +426,6 @@ export interface RawCustomPayload {
sections?: Segment[];
}
export interface Group {
name: string;
secret: BaseGroupSecret;
}
export type PublicKeyValue = string | Uint8Array
export abstract class BasePublicKey {

View File

@@ -24,4 +24,13 @@ export default defineConfig({
},
},
},
build: {
rollupOptions: {
output: {
chunkFileNames: 'assets/[hash].js',
entryFileNames: 'assets/[hash].js',
assetFileNames: 'assets/[hash][extname]'
}
}
},
})