init
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
.idea
|
||||
node_modules
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Zakary Timson
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Multipurpose Radio Server (MRS)
|
||||
|
||||
---
|
||||
|
||||
###### By: Zakary Timson
|
||||
###### Date: September 16, 2025
|
||||
###### Version: 1.0
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
> Compact communication and information exchange server on Raspberry Pi for service-denied areas.
|
||||
> Ideal for disasters, unserviced environments, long-range tracking, and offline-first operations.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Multipurpose Radio Server](#multipurpose-radio-server-mrs)
|
||||
- [Description](#description)
|
||||
- [Inspiration](#inspiration)
|
||||
- [Capabilities](#capabilities)
|
||||
- [Manual](#manual)
|
||||
- [Setup](docs/setup.md)
|
||||
- [Introduction](#introduction) <!-- Home + Settings page -->
|
||||
- [Connecting & Wifi](#connecting)
|
||||
- [Mesh Network](#mesh-network)
|
||||
- [LoRa Radio](#lora-radio)
|
||||
- [FileBrowser](#filebrowser)
|
||||
- [Desktop Environment](#desktop-environment)
|
||||
- Services
|
||||
- [AI Assistant](#ai-assistant)
|
||||
- [Archive](#archive)
|
||||
- [ATAK](#atak)
|
||||
- [Maps](docs/maps.md)
|
||||
- [Messaging](#messaging)
|
||||
- [Pentesting](#pentesting)
|
||||
- [SDR](#sdr)
|
||||
- [SSH](#ssh)
|
||||
- [VPN](#vpn)
|
||||
- [Todo](#todo)
|
||||
- [License](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
### Inspiration
|
||||
- [A.C.I.D](https://www.youtube.com/watch?v=nSrYmkGq9TM)
|
||||
- [Offline internet](https://www.youtube.com/watch?v=L5RJZmuRJKA)
|
||||
- [Meshtastic radios](https://morosx.com/ols/products/xtak-lora-mesh)
|
||||
- [HaLow mesh network](https://www.youtube.com/watch?v=ofR7GFNZzJY)
|
||||
- [Signal Intelligence](https://www.youtube.com/watch?v=Nam87B2u6bo&t=1942s)
|
||||
|
||||
### Capabilities
|
||||
|
||||
**Networking**
|
||||
- 900 MHz **HaLow** / 2.4 GHz / 5 GHz Mesh WiFi using **B.A.T.M.A.N.** (1 km +)
|
||||
- LoRa radio communication (1-100s of kms)
|
||||
- Connect to AP with QR code using **dnsmasq, hostapd**
|
||||
- Captive portal & device home page + settings page
|
||||
|
||||
**Communication**
|
||||
- Offline & federated text/voice/file chat using **Synapse** and **Element**
|
||||
- Built-in LoRa **Meshtastic** node (≈2–15 km)
|
||||
- GPS positioning & offline maps using **gpsd, chrony**
|
||||
- Track people & logistics with **ATAK**
|
||||
|
||||
**Information Archive**
|
||||
- Offline AI/LLM assistant using **Ollama** and **OpenWebUI**
|
||||
- Offline Wikipedia, Ghetesburg project & more using **Kiwix**
|
||||
- File sharing & syncing using **FileBrowser** and **Rclone**
|
||||
- Remote desktop usig **RDP** and **Apache Guacamole**
|
||||
|
||||
**Recon & Security**
|
||||
- WiFi & Bluetooth reconnaissance with **Bettercap, nmap, masscan** and more
|
||||
- Red team operations with **aircrack, nikto, Metasploit** and more
|
||||
- Radio frequency recon using **SDRAngel**
|
||||
- ADS-B plane tracking with **tar1090**
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [MIT License](LICENSE).
|
||||
+370
@@ -0,0 +1,370 @@
|
||||
{
|
||||
"name": "dashboard",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dashboard",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-gpsd": "^0.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.10"
|
||||
}
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"normalize-path": "^3.0.0",
|
||||
"picomatch": "^2.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
"braces": "~3.0.2",
|
||||
"glob-parent": "~5.1.2",
|
||||
"is-binary-path": "~2.1.0",
|
||||
"is-glob": "~4.0.1",
|
||||
"normalize-path": "~3.0.0",
|
||||
"readdirp": "~3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
||||
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore-by-default": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
|
||||
"integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"binary-extensions": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-glob": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/node-gpsd": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/node-gpsd/-/node-gpsd-0.3.4.tgz",
|
||||
"integrity": "sha512-sI9hPfHiaWDmhjE1oJZnhMo7UF2vQVGl3qk0K4HbN1L8BkS0I+rd6V687eHOj/6ervXiHrhwXzYYsWdXxGy0Qg==",
|
||||
"engines": {
|
||||
"node": ">=v0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon": {
|
||||
"version": "3.1.10",
|
||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
|
||||
"integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"chokidar": "^3.5.2",
|
||||
"debug": "^4",
|
||||
"ignore-by-default": "^1.0.1",
|
||||
"minimatch": "^3.1.2",
|
||||
"pstree.remy": "^1.1.8",
|
||||
"semver": "^7.5.3",
|
||||
"simple-update-notifier": "^2.0.0",
|
||||
"supports-color": "^5.5.0",
|
||||
"touch": "^3.1.0",
|
||||
"undefsafe": "^2.0.5"
|
||||
},
|
||||
"bin": {
|
||||
"nodemon": "bin/nodemon.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/nodemon"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pstree.remy": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
|
||||
"integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-update-notifier": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
||||
"integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"has-flag": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/touch": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
|
||||
"integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"nodetouch": "bin/nodetouch.js"
|
||||
}
|
||||
},
|
||||
"node_modules/undefsafe": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||
"integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "dashboard",
|
||||
"version": "1.0.0",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "node src/main.mjs",
|
||||
"start:prod": "nodemon src/main.mjs"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"description": "Dashboard",
|
||||
"dependencies": {
|
||||
"node-gpsd": "^0.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.10"
|
||||
}
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Executable
+953
@@ -0,0 +1,953 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>MRS Dashboard</title>
|
||||
<link rel="icon" href="/favicon.png">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||
min-height: 100vh;
|
||||
color: #fff;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
grid-template-areas: "logo status clocks";
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
grid-area: logo;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.logo-section img {
|
||||
height: 3rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05rem;
|
||||
}
|
||||
|
||||
.hostname {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
grid-area: clocks;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.network-status-wrapper {
|
||||
grid-area: status;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.network-status {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.network-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.4rem;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.network-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.network-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
stroke-width: 2;
|
||||
fill: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.expand-toggle {
|
||||
cursor: pointer;
|
||||
padding: 0.4rem;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.expand-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
stroke: #94a3b8;
|
||||
stroke-width: 2;
|
||||
fill: none;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.expand-icon.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.expanded-status {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease;
|
||||
}
|
||||
|
||||
.expanded-status.visible {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.status-tiles {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem 0;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.status-tiles::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.status-tiles::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.status-tiles::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.status-tiles::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.status-tile {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
justify-content: space-between;
|
||||
flex: 0 0 auto;
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.tile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.tile-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.tile-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05rem;
|
||||
}
|
||||
|
||||
.tile-info {
|
||||
font-size: 0.85rem;
|
||||
color: #94a3b8;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.tile-detail {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.network-item.disconnected .network-icon,
|
||||
.status-tile.disconnected .tile-icon {
|
||||
stroke: #64748b;
|
||||
}
|
||||
|
||||
.status-tile.disconnected .tile-name {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.status-tile.connected {
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.network-item.connected .network-icon,
|
||||
.status-tile.connected .tile-icon {
|
||||
stroke: #10b981;
|
||||
}
|
||||
|
||||
.status-tile.connected .tile-name {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.time-display {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.time-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.time-label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05rem;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.time-value {
|
||||
font-weight: 600;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.time-date {
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 1rem 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-bar:focus-within {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
stroke: #64748b;
|
||||
stroke-width: 2;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.apps-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.app-item {
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.second-action {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px;
|
||||
border-radius: 50%;
|
||||
background: #5a6b63;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.second-action:hover {
|
||||
background: #6a7b73;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.second-action:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.second-action svg {
|
||||
stroke: white;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.app-badge svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
stroke: #fff;
|
||||
stroke-width: 2;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.app-icon-wrapper {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.app-item:hover .app-icon-wrapper {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.app-item:active .app-icon-wrapper {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
#temp {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
stroke: #fff;
|
||||
stroke-width: 1.5;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.tile-wide {
|
||||
width: 360px;
|
||||
}
|
||||
|
||||
.system-metrics {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.system-metric {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: #cdd5e0;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.metric-label span {
|
||||
font-size: 0.7rem;
|
||||
color: #94a3b8;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex-grow: 1;
|
||||
height: 14px;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background: #10b981;
|
||||
border-radius: 6px;
|
||||
transition: width 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.progress-bar-text {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #fff;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Responsive breakpoints */
|
||||
@media (max-width: 800px) {
|
||||
.header-content {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-areas:
|
||||
"logo clocks"
|
||||
"status status";
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.network-status-wrapper {
|
||||
justify-self: center;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 550px) {
|
||||
.header-content {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
"logo"
|
||||
"clocks"
|
||||
"status";
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.time-display {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.time-block {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-content">
|
||||
<div class="logo-section">
|
||||
<img src="/favicon.png" alt="Logo">
|
||||
<div>
|
||||
<div class="logo">DASHBOARD</div>
|
||||
<div class="hostname">Hostname</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-info">
|
||||
<div class="time-display">
|
||||
<div class="time-block">
|
||||
<div class="time-label">Local</div>
|
||||
<div class="time-value" id="local-time">00:00:00</div>
|
||||
<div class="time-date" id="local-date">Jan 1, 2025</div>
|
||||
</div>
|
||||
<div class="time-block">
|
||||
<div class="time-label">Zulu / UTC</div>
|
||||
<div class="time-value" id="zulu-time">00:00:00</div>
|
||||
<div class="time-date" id="zulu-date">Jan 1, 2025</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="network-status-wrapper">
|
||||
<div class="network-status">
|
||||
<div class="network-item disconnected" id="gps-status">
|
||||
<svg class="network-icon" viewBox="0 0 24 24">
|
||||
<g transform="translate(2,2) rotate(-45 12 12)">
|
||||
<rect x="9.5" y="1" width="5" height="8.5" rx="1" ry="1" fill="transparent"/>
|
||||
<rect x="1" y="6" width="6" height="4"/>
|
||||
<rect x="17" y="6" width="6" height="4"/>
|
||||
<line x1="1" y1="8" x2="23" y2="8"/>
|
||||
<defs>
|
||||
<clipPath id="halfCircle">
|
||||
<rect x="0" y="5" width="100%" height="42%" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<circle cx="12" cy="17.5" r="6px" clip-path="url(#halfCircle)" fill="transparent" />
|
||||
<line x1="12" y1="12" x2="12" y2="17" stroke-linecap="round" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="network-item connected" id="lora-status">
|
||||
<svg class="network-icon" viewBox="0 0 24 24">
|
||||
<line x1="0" y1="18" x2="8" y2="6"/>
|
||||
<line x1="8" y1="18" x2="16" y2="6"/>
|
||||
<line x1="16" y1="6" x2="24" y2="18"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="network-item connected" id="mesh-status">
|
||||
<svg class="network-icon" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="5" cy="5" r="2" />
|
||||
<line x1="6" y1="6" x2="15" y2="4" />
|
||||
<line x1="6" y1="6" x2="20" y2="12" />
|
||||
<line x1="6" y1="6" x2="13.5" y2="19" />
|
||||
<line x1="6" y1="6" x2="5" y2="15" />
|
||||
<circle cx="15.5" cy="3" r="2" />
|
||||
<line x1="15" y1="4" x2="20" y2="12" />
|
||||
<line x1="15" y1="4" x2="13.5" y2="19" />
|
||||
<line x1="15" y1="4" x2="5" y2="15" />
|
||||
<circle cx="21" cy="12" r="2" />
|
||||
<line x1="20" y1="12" x2="13.5" y2="19" />
|
||||
<circle cx="14" cy="20.5" r="2" />
|
||||
<line x1="13.5" y1="19" x2="5" y2="15" />
|
||||
<circle cx="4" cy="16" r="2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="network-item connected" id="wifi-status">
|
||||
<svg class="network-icon" viewBox="0 0 24 24">
|
||||
<path d="M5 12.55a11 11 0 0 1 14.08 0"/>
|
||||
<path d="M1.42 9a16 16 0 0 1 21.16 0"/>
|
||||
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"/>
|
||||
<line x1="12" y1="20" x2="12.01" y2="20"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="network-item disconnected" id="ethernet-status">
|
||||
<svg class="network-icon" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="5" y="9" width="14" height="10" rx="1" ry="1"/>
|
||||
<rect x="9" y="5" width="6" height="4" rx="0.5" ry="0.5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="expand-toggle" id="expand-toggle">
|
||||
<svg class="expand-icon" id="expand-icon" viewBox="0 0 24 24">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="expanded-status" id="expanded-status">
|
||||
<div class="status-tiles">
|
||||
<div class="status-tile connected tile-wide" id="system-tile">
|
||||
<div class="tile-header">
|
||||
<svg class="tile-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
|
||||
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
|
||||
<line x1="6" y1="6" x2="6.01" y2="6"></line>
|
||||
<line x1="10" y1="6" x2="10.01" y2="6"></line>
|
||||
<line x1="6" y1="18" x2="6.01" y2="18"></line>
|
||||
<line x1="10" y1="18" x2="10.01" y2="18"></line>
|
||||
</svg>
|
||||
<div class="tile-name">System <span id="temp">?°C</span></div>
|
||||
</div>
|
||||
<div class="system-metrics">
|
||||
<div class="system-metric">
|
||||
<div class="metric-label">CPU <span id="load">- Load</span></div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar-fill" id="cpu-usage-fill"></div>
|
||||
<div class="progress-bar-text" id="cpu-usage-text">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="system-metric">
|
||||
<div class="metric-label">Memory</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar-fill" id="memory-usage-fill"></div>
|
||||
<div class="progress-bar-text" id="memory-usage-text">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="system-metric">
|
||||
<div class="metric-label">Disk</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar-fill" id="disk-usage-fill"></div>
|
||||
<div class="progress-bar-text" id="disk-usage-text">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-tile disconnected" id="gps-tile">
|
||||
<div class="tile-header">
|
||||
<svg class="tile-icon" viewBox="0 0 24 24">
|
||||
<g transform="translate(2,2) rotate(-45 12 12)">
|
||||
<rect x="9.5" y="1" width="5" height="8.5" rx="1" ry="1" fill="transparent" />
|
||||
<rect x="1" y="6" width="6" height="4"/>
|
||||
<rect x="17" y="6" width="6" height="4"/>
|
||||
<line x1="1" y1="8" x2="23" y2="8"/>
|
||||
<defs>
|
||||
<clipPath id="halfCircle">
|
||||
<rect x="0" y="5" width="100%" height="42%" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<circle cx="12" cy="17.5" r="6px" clip-path="url(#halfCircle)" fill="transparent" />
|
||||
<line x1="12" y1="12" x2="12" y2="17" stroke-linecap="round" />
|
||||
</g>
|
||||
</svg>
|
||||
<div class="tile-name">GPS</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="tile-info">39.2000, -49.0000</div>
|
||||
<div class="tile-detail">No Fix</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-tile disconnected" id="lora-tile">
|
||||
<div class="tile-header">
|
||||
<svg class="tile-icon" viewBox="0 0 24 24">
|
||||
<line x1="0" y1="18" x2="8" y2="6"/>
|
||||
<line x1="8" y1="18" x2="16" y2="6"/>
|
||||
<line x1="16" y1="6" x2="24" y2="18"/>
|
||||
</svg>
|
||||
<div class="tile-name">LoRa</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="tile-info">0 / 0 Online</div>
|
||||
<div class="tile-detail">Ch 0% Air 0%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-tile connected" id="mesh-tile">
|
||||
<div class="tile-header">
|
||||
<svg class="tile-icon" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="5" cy="5" r="2" />
|
||||
<line x1="6" y1="6" x2="15" y2="4" />
|
||||
<line x1="6" y1="6" x2="20" y2="12" />
|
||||
<line x1="6" y1="6" x2="13.5" y2="19" />
|
||||
<line x1="6" y1="6" x2="5" y2="15" />
|
||||
<circle cx="15.5" cy="3" r="2" />
|
||||
<line x1="15" y1="4" x2="20" y2="12" />
|
||||
<line x1="15" y1="4" x2="13.5" y2="19" />
|
||||
<line x1="15" y1="4" x2="5" y2="15" />
|
||||
<circle cx="21" cy="12" r="2" />
|
||||
<line x1="20" y1="12" x2="13.5" y2="19" />
|
||||
<circle cx="14" cy="20.5" r="2" />
|
||||
<line x1="13.5" y1="19" x2="5" y2="15" />
|
||||
<circle cx="4" cy="16" r="2" />
|
||||
</svg>
|
||||
<div class="tile-name">Mesh</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="tile-info">Connected</div>
|
||||
<div class="tile-detail">900 Mhz -45 dBm</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-tile connected" id="wifi-tile">
|
||||
<div class="tile-header">
|
||||
<svg class="tile-icon" viewBox="0 0 24 24">
|
||||
<path d="M5 12.55a11 11 0 0 1 14.08 0"/>
|
||||
<path d="M1.42 9a16 16 0 0 1 21.16 0"/>
|
||||
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"/>
|
||||
<line x1="12" y1="20" x2="12.01" y2="20"/>
|
||||
</svg>
|
||||
<div class="tile-name">WiFi</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="tile-info">Access Point</div>
|
||||
<div class="tile-detail">SSID: Hostname</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-tile disconnected" id="ethernet-tile">
|
||||
<div class="tile-header">
|
||||
<svg class="tile-icon" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="5" y="9" width="14" height="10" rx="1" ry="1"/>
|
||||
<rect x="9" y="5" width="6" height="4" rx="0.5" ry="0.5"/>
|
||||
</svg>
|
||||
<div class="tile-name">Ethernet</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="tile-info">Disconnected</div>
|
||||
<div class="tile-detail">No cable detected</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="search-bar">
|
||||
<svg class="search-icon" viewBox="0 0 24 24">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.35-4.35"/>
|
||||
</svg>
|
||||
<input type="text" class="search-input" placeholder="Search apps..." id="search-input">
|
||||
</div>
|
||||
|
||||
<div class="apps-grid" id="apps-grid">
|
||||
<a href=":1000" class="app-item" data-name="AI Assistant">
|
||||
<div class="app-icon-wrapper">
|
||||
<svg class="app-icon" viewBox="0 0 24 24">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2" ry="2"/>
|
||||
<line x1="2" y1="9" x2="6" y2="9"/>
|
||||
<line x1="2" y1="12" x2="6" y2="12"/>
|
||||
<line x1="2" y1="15" x2="6" y2="15"/>
|
||||
<line x1="18" y1="9" x2="22" y2="9"/>
|
||||
<line x1="18" y1="12" x2="22" y2="12"/>
|
||||
<line x1="18" y1="15" x2="22" y2="15"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="app-name">AI Assistant</div>
|
||||
</a>
|
||||
|
||||
<a href=":1100" class="app-item" data-name="ATAK">
|
||||
<div class="app-icon-wrapper">
|
||||
<svg class="app-icon" viewBox="0 0 24 24">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
<div class="second-action">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
|
||||
<line x1="12" y1="0" x2="12" y2="24" />
|
||||
<line x1="4" y1="11" x2="12" y2="21" />
|
||||
<line x1="20" y1="11" x2="12" y2="21" />
|
||||
<line x1="1" y1="22" x2="23" y2="22" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-name">ATAK</div>
|
||||
</a>
|
||||
|
||||
<a href=":1200" class="app-item" data-name="Remote Desktop">
|
||||
<div class="app-icon-wrapper">
|
||||
<svg class="app-icon" viewBox="0 0 24 24">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
<div class="second-action">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
|
||||
<line x1="12" y1="0" x2="12" y2="24" />
|
||||
<line x1="4" y1="11" x2="12" y2="21" />
|
||||
<line x1="20" y1="11" x2="12" y2="21" />
|
||||
<line x1="1" y1="22" x2="23" y2="22" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-name">Desktop</div>
|
||||
</a>
|
||||
|
||||
<a href=":1300" class="app-item" data-name="File Browser">
|
||||
<div class="app-icon-wrapper">
|
||||
<svg class="app-icon" viewBox="0 0 24 24">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="app-name">File Browser</div>
|
||||
</a>
|
||||
|
||||
<a href=":1400" class="app-item" data-name="Library">
|
||||
<div class="app-icon-wrapper">
|
||||
<svg class="app-icon" viewBox="0 0 24 24">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
|
||||
</svg>
|
||||
<div class="second-action" onclick="openKiwixm(event)">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
|
||||
<circle cx="12" cy="12" r="6.5" stroke-width="5" />
|
||||
<line x1="12" y1="7" x2="12" y2=".5" />
|
||||
<line x1="12" y1="17" x2="12" y2="23.5" />
|
||||
<line x1="0.5" y1="12" x2="7" y2="12" />
|
||||
<line x1="17" y1="12" x2="23.5" y2="12" />
|
||||
<line x1="15" y1="15" x2="20" y2="20" />
|
||||
<line x1="4" y1="4" x2="9" y2="9" />
|
||||
<line x1="20" y1="4" x2="15" y2="9" />
|
||||
<line x1="4" y1="20" x2="9" y2="15" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-name">Library</div>
|
||||
</a>
|
||||
|
||||
<a href=":1500" class="app-item" data-name="Maps">
|
||||
<div class="app-icon-wrapper">
|
||||
<svg class="app-icon" viewBox="0 0 24 24">
|
||||
<path d="M3 7l6-3 6 3 6-3v13l-6 3-6-3-6 3z"/>
|
||||
<line x1="9" y1="4" x2="9" y2="17"/>
|
||||
<line x1="15" y1="7" x2="15" y2="20"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="app-name">Maps</div>
|
||||
</a>
|
||||
|
||||
<a href=":1600" class="app-item" data-name="Meshtastic">
|
||||
<div class="app-icon-wrapper">
|
||||
<svg class="app-icon" viewBox="0 0 24 24">
|
||||
<line x1="0" y1="18" x2="8" y2="6"/>
|
||||
<line x1="8" y1="18" x2="16" y2="6"/>
|
||||
<line x1="16" y1="6" x2="24" y2="18"/>
|
||||
</svg>
|
||||
<div class="second-action">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
|
||||
<line x1="12" y1="0" x2="12" y2="24" />
|
||||
<line x1="4" y1="11" x2="12" y2="21" />
|
||||
<line x1="20" y1="11" x2="12" y2="21" />
|
||||
<line x1="1" y1="22" x2="23" y2="22" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-name">Meshtastic</div>
|
||||
</a>
|
||||
|
||||
<a href=":1700" class="app-item" data-name="Messages">
|
||||
<div class="app-icon-wrapper">
|
||||
<svg class="app-icon" viewBox="0 0 24 24">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
<div class="second-action">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
|
||||
<line x1="12" y1="0" x2="12" y2="24" />
|
||||
<line x1="4" y1="11" x2="12" y2="21" />
|
||||
<line x1="20" y1="11" x2="12" y2="21" />
|
||||
<line x1="1" y1="22" x2="23" y2="22" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-name">Messages</div>
|
||||
</a>
|
||||
|
||||
<a href=":1800" class="app-item" data-name="Pentesting">
|
||||
<div class="app-icon-wrapper">
|
||||
<svg class="app-icon" viewBox="0 0 24 24">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="app-name">Pentesting</div>
|
||||
</a>
|
||||
|
||||
<a href=":1900" class="app-item" data-name="Software Defined Radio">
|
||||
<div class="app-icon-wrapper">
|
||||
<svg class="app-icon" viewBox="0 0 24 24">
|
||||
<g transform="translate(0,-2)">
|
||||
<circle cx="12" cy="12" r="1.5"/>
|
||||
<path d="M16.24 7.76a6 6 0 0 1 0 8.49"/>
|
||||
<path d="M7.76 16.24a6 6 0 0 1 0-8.49"/>
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
|
||||
<path d="M4.93 19.07a10 10 0 0 1 0-14.14"/>
|
||||
<line x1="12" y1="10" x2="12" y2="24"/>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="second-action">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
|
||||
<line x1="12" y1="0" x2="12" y2="24" />
|
||||
<line x1="4" y1="11" x2="12" y2="21" />
|
||||
<line x1="20" y1="11" x2="12" y2="21" />
|
||||
<line x1="1" y1="22" x2="23" y2="22" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-name">SDR</div>
|
||||
</a>
|
||||
|
||||
<a href=":2000" class="app-item" data-name="Speed Test">
|
||||
<div class="app-icon-wrapper">
|
||||
<svg class="app-icon" viewBox="0 0 24 24">
|
||||
<polygon points="8 1 16 1 14 15 10 15"/>
|
||||
<circle cx="12" cy="20" r="3"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="app-name">Situation Room</div>
|
||||
</a>
|
||||
|
||||
<a href=":2100" class="app-item" data-name="Speed Test">
|
||||
<div class="app-icon-wrapper">
|
||||
<svg class="app-icon" viewBox="0 0 24 24">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="app-name">Speed Test</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+368
@@ -0,0 +1,368 @@
|
||||
|
||||
class Dashboard {
|
||||
constructor() {
|
||||
this.statusCache = null;
|
||||
this.lastFetch = 0;
|
||||
this.hostname = 'localhost';
|
||||
this.serverTimeOffset = null; // Track difference between server and client time
|
||||
this.isTimeServerSynced = false; // Track if we're using server time
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.fetchHostname();
|
||||
this.updateHostname();
|
||||
this.startTimeUpdates(); // Start with client time immediately
|
||||
this.startStatusUpdates();
|
||||
this.initializeEventListeners();
|
||||
}
|
||||
|
||||
async fetchHostname() {
|
||||
try {
|
||||
const response = await fetch('/api/hostname');
|
||||
const data = await response.json();
|
||||
this.hostname = data.hostname.toLowerCase();
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch hostname:', error);
|
||||
}
|
||||
}
|
||||
|
||||
updateHostname() {
|
||||
const hostnameElement = document.querySelector('.hostname');
|
||||
if (hostnameElement) {
|
||||
hostnameElement.textContent = this.hostname;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchStatus() {
|
||||
try {
|
||||
// Record start time for latency calculation
|
||||
const requestStart = performance.now();
|
||||
const response = await fetch('/api/status');
|
||||
const requestEnd = performance.now();
|
||||
|
||||
if (!response.ok) throw new Error('Status fetch failed');
|
||||
|
||||
const status = await response.json();
|
||||
this.statusCache = status;
|
||||
this.updateUI(status, requestStart, requestEnd);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch status:', error);
|
||||
this.showOfflineStatus();
|
||||
}
|
||||
}
|
||||
|
||||
updateUI(status, requestStart, requestEnd) {
|
||||
this.updateSystemMetrics(status.system);
|
||||
this.updateGPSStatus(status.gps);
|
||||
this.updateNetworkStatus(status.network);
|
||||
this.updateLoRaStatus(status.lora);
|
||||
this.updateTimeSync(status.time, requestStart, requestEnd);
|
||||
}
|
||||
|
||||
updateSystemMetrics(system) {
|
||||
// Update temperature
|
||||
const tempElement = document.getElementById('temp');
|
||||
if (tempElement && system.temperature) {
|
||||
tempElement.textContent = `${system.temperature}°C`;
|
||||
}
|
||||
|
||||
// Update load average
|
||||
const loadElement = document.getElementById('load');
|
||||
if (loadElement && system.load !== undefined) {
|
||||
loadElement.textContent = `${system.load.toFixed(1)} Load`;
|
||||
}
|
||||
|
||||
// Update progress bars
|
||||
this.updateProgressBar('cpu-usage', system.cpu);
|
||||
this.updateProgressBar('memory-usage', system.memory);
|
||||
this.updateProgressBar('disk-usage', system.storage);
|
||||
}
|
||||
|
||||
updateProgressBar(id, percentage) {
|
||||
const fillElement = document.getElementById(`${id}-fill`);
|
||||
const textElement = document.getElementById(`${id}-text`);
|
||||
|
||||
if (fillElement && textElement) {
|
||||
const value = Math.round(percentage);
|
||||
fillElement.style.width = `${value}%`;
|
||||
textElement.textContent = `${value}%`;
|
||||
|
||||
// Update color based on usage
|
||||
if (value > 80) {
|
||||
fillElement.style.background = '#ef4444'; // Red
|
||||
} else if (value > 60) {
|
||||
fillElement.style.background = '#f59e0b'; // Orange
|
||||
} else {
|
||||
fillElement.style.background = '#10b981'; // Green
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateGPSStatus(gps) {
|
||||
const gpsStatus = document.getElementById('gps-status');
|
||||
const gpsTile = document.getElementById('gps-tile');
|
||||
|
||||
if (gpsStatus && gpsTile) {
|
||||
const hasValidFix = gps.fix !== 'No Fix' && gps.latitude !== null;
|
||||
|
||||
// Update status indicator
|
||||
if (hasValidFix) {
|
||||
gpsStatus.classList.remove('disconnected');
|
||||
gpsStatus.classList.add('connected');
|
||||
gpsTile.classList.remove('disconnected');
|
||||
gpsTile.classList.add('connected');
|
||||
} else {
|
||||
gpsStatus.classList.remove('connected');
|
||||
gpsStatus.classList.add('disconnected');
|
||||
gpsTile.classList.remove('connected');
|
||||
gpsTile.classList.add('disconnected');
|
||||
}
|
||||
|
||||
// Update GPS tile info
|
||||
const gpsInfo = gpsTile.querySelector('.tile-info');
|
||||
const gpsDetail = gpsTile.querySelector('.tile-detail');
|
||||
|
||||
if (gpsInfo && gpsDetail) {
|
||||
if (hasValidFix) {
|
||||
gpsInfo.textContent = `${gps.latitude.toFixed(4)}, ${gps.longitude.toFixed(4)}`;
|
||||
gpsDetail.textContent = `${gps.fix} • ${gps.satellites} sats • Alt: ${Math.round(gps.altitude)}m`;
|
||||
} else {
|
||||
gpsInfo.textContent = gps.satellites > 0 ? 'Acquiring Fix...' : 'No Signal';
|
||||
gpsDetail.textContent = `${gps.satellites} satellites`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateNetworkStatus(network) {
|
||||
// Update WiFi status
|
||||
const wifiTile = document.getElementById('wifi-tile');
|
||||
if (wifiTile) {
|
||||
const wifiInfo = wifiTile.querySelector('.tile-info');
|
||||
const wifiDetail = wifiTile.querySelector('.tile-detail');
|
||||
|
||||
if (wifiInfo && wifiDetail) {
|
||||
if (network.wifi.mode === 'ap') {
|
||||
wifiInfo.textContent = 'Access Point';
|
||||
wifiDetail.textContent = `SSID: ${network.wifi.ssid}`;
|
||||
} else {
|
||||
wifiInfo.textContent = 'Client Mode';
|
||||
wifiDetail.textContent = `SSID: ${network.wifi.ssid}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update Ethernet status
|
||||
const ethernetStatus = document.getElementById('ethernet-status');
|
||||
const ethernetTile = document.getElementById('ethernet-tile');
|
||||
|
||||
if (ethernetStatus && ethernetTile) {
|
||||
if (network.ethernet.connected) {
|
||||
ethernetStatus.classList.remove('disconnected');
|
||||
ethernetStatus.classList.add('connected');
|
||||
ethernetTile.classList.remove('disconnected');
|
||||
ethernetTile.classList.add('connected');
|
||||
|
||||
const ethernetInfo = ethernetTile.querySelector('.tile-info');
|
||||
const ethernetDetail = ethernetTile.querySelector('.tile-detail');
|
||||
if (ethernetInfo && ethernetDetail) {
|
||||
ethernetInfo.textContent = 'Connected';
|
||||
ethernetDetail.textContent = 'Link detected';
|
||||
}
|
||||
} else {
|
||||
ethernetStatus.classList.remove('connected');
|
||||
ethernetStatus.classList.add('disconnected');
|
||||
ethernetTile.classList.remove('connected');
|
||||
ethernetTile.classList.add('disconnected');
|
||||
|
||||
const ethernetInfo = ethernetTile.querySelector('.tile-info');
|
||||
const ethernetDetail = ethernetTile.querySelector('.tile-detail');
|
||||
if (ethernetInfo && ethernetDetail) {
|
||||
ethernetInfo.textContent = 'Disconnected';
|
||||
ethernetDetail.textContent = 'No cable detected';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateLoRaStatus(lora) {
|
||||
const loraStatus = document.getElementById('lora-status');
|
||||
const loraTile = document.getElementById('lora-tile');
|
||||
|
||||
if (loraStatus && loraTile) {
|
||||
if (lora.connected) {
|
||||
loraStatus.classList.remove('disconnected');
|
||||
loraStatus.classList.add('connected');
|
||||
loraTile.classList.remove('disconnected');
|
||||
loraTile.classList.add('connected');
|
||||
|
||||
const loraInfo = loraTile.querySelector('.tile-info');
|
||||
const loraDetail = loraTile.querySelector('.tile-detail');
|
||||
|
||||
if (loraInfo && loraDetail) {
|
||||
loraInfo.textContent = `${lora.onlineNodes}/${lora.totalNodes} Online`;
|
||||
loraDetail.textContent = `CH: ${lora.channelUtilization.toFixed(1)}% • Air: ${lora.airtimeUtilization.toFixed(1)}%`;
|
||||
}
|
||||
} else {
|
||||
loraStatus.classList.remove('connected');
|
||||
loraStatus.classList.add('disconnected');
|
||||
loraTile.classList.remove('connected');
|
||||
loraTile.classList.add('disconnected');
|
||||
|
||||
const loraInfo = loraTile.querySelector('.tile-info');
|
||||
const loraDetail = loraTile.querySelector('.tile-detail');
|
||||
|
||||
if (loraInfo && loraDetail) {
|
||||
loraInfo.textContent = 'Disconnected';
|
||||
loraDetail.textContent = 'No device found';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateTimeSync(serverTimeString, requestStart, requestEnd) {
|
||||
if (serverTimeString && requestStart && requestEnd) {
|
||||
// Calculate network latency (round-trip time / 2)
|
||||
const networkLatency = (requestEnd - requestStart) / 2;
|
||||
|
||||
// Parse server time and adjust for network latency
|
||||
const serverTime = new Date(serverTimeString);
|
||||
const adjustedServerTime = new Date(serverTime.getTime() + networkLatency);
|
||||
|
||||
// Calculate the offset between adjusted server time and client time
|
||||
const clientTime = new Date();
|
||||
this.serverTimeOffset = adjustedServerTime.getTime() - clientTime.getTime();
|
||||
this.isTimeServerSynced = true;
|
||||
|
||||
console.log(`Time synchronized with server (latency: ${networkLatency.toFixed(1)}ms, offset: ${this.serverTimeOffset.toFixed(1)}ms)`);
|
||||
}
|
||||
|
||||
// Update time displays
|
||||
this.updateCurrentTime();
|
||||
}
|
||||
|
||||
updateCurrentTime() {
|
||||
const now = new Date();
|
||||
let displayTime;
|
||||
|
||||
if (this.isTimeServerSynced && this.serverTimeOffset !== null) {
|
||||
// Use server-synchronized time
|
||||
displayTime = new Date(now.getTime() + this.serverTimeOffset);
|
||||
} else {
|
||||
// Use client time (default)
|
||||
displayTime = now;
|
||||
}
|
||||
|
||||
this.updateTimeDisplay('local', displayTime, false);
|
||||
this.updateTimeDisplay('zulu', displayTime, true);
|
||||
}
|
||||
|
||||
updateTimeDisplay(type, date, isUtc) {
|
||||
const timeElement = document.getElementById(`${type}-time`);
|
||||
const dateElement = document.getElementById(`${type}-date`);
|
||||
|
||||
if (timeElement && dateElement) {
|
||||
const displayDate = isUtc ? new Date(date.getTime()) : date;
|
||||
|
||||
const timeStr = isUtc ?
|
||||
displayDate.toUTCString().split(' ')[4] :
|
||||
displayDate.toLocaleTimeString();
|
||||
|
||||
const dateStr = isUtc ?
|
||||
displayDate.toUTCString().split(' ').slice(0, 4).join(' ') :
|
||||
displayDate.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
|
||||
timeElement.textContent = timeStr;
|
||||
dateElement.textContent = dateStr;
|
||||
|
||||
// Color time orange when using client time, white when server-synced
|
||||
if (this.isTimeServerSynced) {
|
||||
timeElement.style.color = '#fff';
|
||||
dateElement.style.color = '#64748b';
|
||||
} else {
|
||||
timeElement.style.color = '#f59e0b';
|
||||
dateElement.style.color = '#f59e0b';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showOfflineStatus() {
|
||||
// Reset to client time and show orange color
|
||||
this.serverTimeOffset = null;
|
||||
this.isTimeServerSynced = false;
|
||||
console.warn('System appears offline - falling back to client time');
|
||||
}
|
||||
|
||||
startStatusUpdates() {
|
||||
// Initial fetch
|
||||
this.fetchStatus();
|
||||
|
||||
// Update every 60 seconds
|
||||
setInterval(() => {
|
||||
this.fetchStatus();
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
startTimeUpdates() {
|
||||
// Start immediately with client time
|
||||
this.updateCurrentTime();
|
||||
|
||||
// Update time every second
|
||||
setInterval(() => {
|
||||
this.updateCurrentTime();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
initializeEventListeners() {
|
||||
// Expand/collapse status
|
||||
const expandToggle = document.getElementById('expand-toggle');
|
||||
const expandedStatus = document.getElementById('expanded-status');
|
||||
const expandIcon = document.getElementById('expand-icon');
|
||||
|
||||
if (expandToggle && expandedStatus && expandIcon) {
|
||||
expandToggle.addEventListener('click', () => {
|
||||
expandedStatus.classList.toggle('visible');
|
||||
expandIcon.classList.toggle('expanded');
|
||||
});
|
||||
}
|
||||
|
||||
// Search functionality
|
||||
const searchInput = document.getElementById('search-input');
|
||||
const appsGrid = document.getElementById('apps-grid');
|
||||
|
||||
if (searchInput && appsGrid) {
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
const query = e.target.value.toLowerCase();
|
||||
const apps = appsGrid.querySelectorAll('.app-item');
|
||||
|
||||
apps.forEach(app => {
|
||||
const name = app.getAttribute('data-name')?.toLowerCase() || '';
|
||||
app.style.display = name.includes(query) ? 'flex' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openKiwixm(event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
window.location.href = window.location.origin.replace(/:\d+/g, '') + ':1300/admin';
|
||||
}
|
||||
|
||||
// Initialize dashboard when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new Dashboard();
|
||||
|
||||
document.querySelectorAll('.app-item[href^=":"]').forEach(link => {
|
||||
const port = link.getAttribute('href').substring(1); // Remove the ':'
|
||||
const url = new URL(location.origin);
|
||||
url.port = port;
|
||||
link.href = url.toString();
|
||||
});
|
||||
});
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "MRS",
|
||||
"short_name": "MRS",
|
||||
"description": "Multipurpose Radio Server",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#000000",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"lang": "en-US",
|
||||
"scope": "/"
|
||||
}
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
|
||||
import express from "express";
|
||||
import { join } from 'path';
|
||||
import {environment} from './services/environment.mjs';
|
||||
import { statusRouter } from './services/status.mjs';
|
||||
|
||||
(async () => {
|
||||
const app = express();
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Middleware error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
// Routes
|
||||
console.log(join(environment.root, '../public'));
|
||||
app.use(express.static(join(environment.root, '../public')));
|
||||
|
||||
app.use('/api', statusRouter);
|
||||
|
||||
// Error handler
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Unhandled error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
// Startup
|
||||
const server = app.listen(environment.port, () => {
|
||||
console.log(`Dashboard running on http://localhost:${environment.port}`);
|
||||
}).on('error', (err) => {
|
||||
console.error('Server startup error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Shutdown
|
||||
const gracefulShutdown = (signal) => {
|
||||
console.log(`Received ${signal}, shutting down gracefully`);
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
console.error('Error during server shutdown:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
})();
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
import dotenv from 'dotenv';
|
||||
import * as fs from 'node:fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
import { execSync } from "child_process";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export const environment = {
|
||||
config: process.env.CONFIG || 'config.json',
|
||||
hostname: process.env.HOSTNAME || execSync('hostname', { encoding: "utf-8" }) || 'localhost',
|
||||
gpsd: process.env.GPSD || 2947,
|
||||
port: process.env.PORT || 80,
|
||||
root: dirname(dirname(fileURLToPath(import.meta.url))),
|
||||
settings: {},
|
||||
}
|
||||
|
||||
if(!environment.config.startsWith('/')) environment.config = `${environment.root}/${environment.config}`;
|
||||
|
||||
export function loadSettings() {
|
||||
if(!fs.existsSync(environment.config)) fs.writeFileSync(environment.config, '', { flag: 'a' });
|
||||
const value = fs.readFileSync(environment.config, 'utf-8');
|
||||
try { return JSON.parse(value); }
|
||||
catch (e) {
|
||||
return {
|
||||
lora: {
|
||||
mode: 'MESH',
|
||||
telemetry: true,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSettings() {
|
||||
fs.writeFileSync(environment.config, JSON.stringify(environment.settings, null, 4));
|
||||
}
|
||||
|
||||
loadSettings();
|
||||
Executable
+426
@@ -0,0 +1,426 @@
|
||||
|
||||
import express from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import pkg from 'node-gpsd';
|
||||
const { GPS } = pkg;
|
||||
import {environment} from './environment.mjs';
|
||||
|
||||
const router = express.Router();
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Status cache with TTL
|
||||
let statusCache = null;
|
||||
let lastUpdate = 0;
|
||||
const CACHE_TTL = 15 * 1000; // 15 seconds
|
||||
|
||||
// GPS client setup
|
||||
let gpsClient = null;
|
||||
let gpsData = {
|
||||
fix: 'No Fix',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
altitude: null,
|
||||
satellites: 0
|
||||
};
|
||||
|
||||
// Initialize GPS connection
|
||||
async function initGPS() {
|
||||
try {
|
||||
gpsClient = new GPS({ hostname: 'localhost', port: 2947 });
|
||||
|
||||
gpsClient.on('TPV', (data) => {
|
||||
gpsData.latitude = data.lat || null;
|
||||
gpsData.longitude = data.lon || null;
|
||||
gpsData.altitude = data.alt || null;
|
||||
gpsData.fix = data.mode === 3 ? '3D Fix' : data.mode === 2 ? '2D Fix' : 'No Fix';
|
||||
});
|
||||
|
||||
gpsClient.on('SKY', (data) => {
|
||||
gpsData.satellites = (data.satellites || []).length;
|
||||
});
|
||||
|
||||
gpsClient.on('error', (err) => {
|
||||
console.warn('GPS error:', err.message);
|
||||
});
|
||||
|
||||
await gpsClient.watch();
|
||||
console.log('GPS client initialized');
|
||||
} catch (error) {
|
||||
console.warn('GPS not available:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// CLI-based system information gathering
|
||||
async function getSystemInfo() {
|
||||
const info = {
|
||||
temperature: 0,
|
||||
load: 0,
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
storage: 0
|
||||
};
|
||||
|
||||
try {
|
||||
// CPU temperature (Raspberry Pi specific)
|
||||
if (existsSync('/sys/class/thermal/thermal_zone0/temp')) {
|
||||
const tempData = readFileSync('/sys/class/thermal/thermal_zone0/temp', 'utf8');
|
||||
info.temperature = Math.round(parseInt(tempData.trim()) / 1000);
|
||||
}
|
||||
|
||||
// Load average
|
||||
const { stdout: loadAvg } = await execAsync("cat /proc/loadavg | awk '{print $1}'");
|
||||
info.load = parseFloat(loadAvg.trim()) || 0;
|
||||
|
||||
// CPU usage
|
||||
const { stdout: cpuUsage } = await execAsync("grep 'cpu ' /proc/stat | awk '{usage=($2+$4)*100/($2+$3+$4+$5)} END {print usage}'");
|
||||
info.cpu = parseFloat(cpuUsage.trim()) || 0;
|
||||
|
||||
// Memory usage percentage
|
||||
const { stdout: memUsage } = await execAsync("free | awk 'NR==2{printf \"%.1f\", $3*100/$2}'");
|
||||
info.memory = parseFloat(memUsage.trim()) || 0;
|
||||
|
||||
// Disk usage percentage for root filesystem
|
||||
const { stdout: diskUsage } = await execAsync("df / | awk 'NR==2{printf \"%.1f\", $5}' | sed 's/%//'");
|
||||
info.storage = parseFloat(diskUsage.trim()) || 0;
|
||||
|
||||
} catch (error) {
|
||||
console.warn('System info error:', error.message);
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
async function getGPSInfo() {
|
||||
const gpsInfo = {
|
||||
satellites: 0,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
altitude: null
|
||||
};
|
||||
|
||||
try {
|
||||
// Check if gpsd is running
|
||||
await execAsync('systemctl is-active gpsd');
|
||||
|
||||
// Try to get GPS data using gpspipe
|
||||
const { stdout: gpsRaw } = await execAsync('timeout 3s gpspipe -w -n 5 | grep -E \'"class":"TPV"\' | head -1', { timeout: 4000 });
|
||||
|
||||
if (gpsRaw.trim()) {
|
||||
const gpsJson = JSON.parse(gpsRaw.trim());
|
||||
gpsInfo.latitude = gpsJson.lat || null;
|
||||
gpsInfo.longitude = gpsJson.lon || null;
|
||||
gpsInfo.altitude = gpsJson.alt || null;
|
||||
}
|
||||
|
||||
// Get satellite count
|
||||
try {
|
||||
const { stdout: skyRaw } = await execAsync('timeout 2s gpspipe -w -n 3 | grep -E \'"class":"SKY"\' | head -1', { timeout: 3000 });
|
||||
if (skyRaw.trim()) {
|
||||
const skyJson = JSON.parse(skyRaw.trim());
|
||||
gpsInfo.satellites = (skyJson.satellites || []).length;
|
||||
}
|
||||
} catch (skyError) { }
|
||||
} catch (error) {
|
||||
console.warn('GPS info error:', error.message);
|
||||
// Use cached GPS data from node-gpsd if available
|
||||
if (gpsData.latitude !== null) {
|
||||
return gpsData;
|
||||
}
|
||||
}
|
||||
|
||||
return gpsInfo;
|
||||
}
|
||||
|
||||
async function getNetworkInfo() {
|
||||
const networkInfo = {
|
||||
wifi: {
|
||||
mode: 'client',
|
||||
ssid: 'Unknown'
|
||||
},
|
||||
ethernet: {
|
||||
connected: false
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Check if hostapd is running (AP mode)
|
||||
try {
|
||||
await execAsync('systemctl is-active hostapd');
|
||||
networkInfo.wifi.mode = 'ap';
|
||||
|
||||
// Get AP SSID from hostapd config or use hostname
|
||||
try {
|
||||
const { stdout: hostname } = await execAsync('hostname');
|
||||
networkInfo.wifi.ssid = process.env.AP_SSID || hostname.trim();
|
||||
} catch {
|
||||
networkInfo.wifi.ssid = 'MRS-AP';
|
||||
}
|
||||
} catch {
|
||||
// Not in AP mode, check if connected to WiFi
|
||||
try {
|
||||
// First, try iwconfig directly since it's more reliable for ESSID
|
||||
const { stdout: iwconfig } = await execAsync("iwconfig wlan0 2>/dev/null");
|
||||
const essidMatch = iwconfig.match(/ESSID:"([^"]+)"/);
|
||||
|
||||
if (essidMatch && essidMatch[1] && essidMatch[1] !== 'off/any') {
|
||||
networkInfo.wifi.mode = 'client';
|
||||
networkInfo.wifi.ssid = essidMatch[1];
|
||||
} else {
|
||||
// Fallback to nmcli if iwconfig doesn't work
|
||||
try {
|
||||
const { stdout: activeWifi } = await execAsync("nmcli -t -f NAME,TYPE,DEVICE connection show --active | grep -E '(wifi|802-11-wireless)' | cut -d':' -f1 | head -1");
|
||||
if (activeWifi.trim()) {
|
||||
networkInfo.wifi.mode = 'client';
|
||||
networkInfo.wifi.ssid = activeWifi.trim();
|
||||
} else {
|
||||
// Try getting SSID from currently connected interface
|
||||
const { stdout: connectedSSID } = await execAsync("nmcli -t -f active,ssid dev wifi | grep '^yes' | cut -d':' -f2 | head -1");
|
||||
if (connectedSSID.trim()) {
|
||||
networkInfo.wifi.mode = 'client';
|
||||
networkInfo.wifi.ssid = connectedSSID.trim();
|
||||
} else {
|
||||
throw new Error('No active WiFi connection found');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Final fallback: check if wlan0 interface is up and get info via ip
|
||||
const { stdout: wlanStatus } = await execAsync("ip addr show wlan0 2>/dev/null | grep 'inet ' | wc -l");
|
||||
if (parseInt(wlanStatus.trim()) > 0) {
|
||||
// Interface has IP but we can't determine SSID
|
||||
networkInfo.wifi.mode = 'client';
|
||||
networkInfo.wifi.ssid = 'Connected (Unknown SSID)';
|
||||
} else {
|
||||
networkInfo.wifi.mode = 'disconnected';
|
||||
networkInfo.wifi.ssid = 'Not Connected';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('WiFi detection error:', error.message);
|
||||
networkInfo.wifi.mode = 'disconnected';
|
||||
networkInfo.wifi.ssid = 'Not Connected';
|
||||
}
|
||||
}
|
||||
|
||||
// Check Ethernet status
|
||||
try {
|
||||
const { stdout: ethStatus } = await execAsync("ip link show eth0 | grep 'state UP'");
|
||||
networkInfo.ethernet.connected = ethStatus.includes('state UP');
|
||||
} catch {
|
||||
networkInfo.ethernet.connected = false;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Network info error:', error.message);
|
||||
}
|
||||
|
||||
return networkInfo;
|
||||
}
|
||||
|
||||
let loraInfo = null;
|
||||
async function getLoRaInfo() {
|
||||
if(loraInfo) return loraInfo;
|
||||
|
||||
loraInfo = {
|
||||
connected: false,
|
||||
onlineNodes: 0,
|
||||
totalNodes: 0,
|
||||
channelUtilization: 0,
|
||||
airtimeUtilization: 0
|
||||
};
|
||||
|
||||
try {
|
||||
console.log('Getting LoRa/Meshtastic status...');
|
||||
|
||||
// Get node information first
|
||||
const { stdout: nodeList } = await execAsync('timeout 10s meshtastic --nodes 2>/dev/null', { timeout: 15000 });
|
||||
|
||||
if (nodeList && nodeList.trim()) {
|
||||
loraInfo.connected = true;
|
||||
|
||||
// Parse node list - look for actual node entries
|
||||
const lines = nodeList.split('\n');
|
||||
let totalNodes = 0;
|
||||
let onlineNodes = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// Skip header lines and separators
|
||||
if (trimmedLine.includes('│') &&
|
||||
!trimmedLine.includes('User') &&
|
||||
!trimmedLine.includes('───') &&
|
||||
!trimmedLine.includes('Node') &&
|
||||
trimmedLine.length > 10) {
|
||||
|
||||
totalNodes++;
|
||||
|
||||
// Check if node is online (has recent lastHeard or SNR data)
|
||||
if (trimmedLine.includes('SNR') ||
|
||||
trimmedLine.match(/\d+[smh]/) || // Time indicators like 5m, 2h, 30s
|
||||
trimmedLine.includes('now')) {
|
||||
onlineNodes++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loraInfo.totalNodes = totalNodes;
|
||||
loraInfo.onlineNodes = onlineNodes;
|
||||
}
|
||||
|
||||
// Get channel and airtime utilization from --info
|
||||
try {
|
||||
const { stdout: meshInfo } = await execAsync('timeout 10s meshtastic --info 2>/dev/null', { timeout: 15000 });
|
||||
|
||||
if (meshInfo && meshInfo.trim()) {
|
||||
const lines = meshInfo.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// Look for channel utilization (various formats)
|
||||
if (trimmedLine.includes('channelUtilization') || trimmedLine.includes('Channel utilization')) {
|
||||
const channelMatch = trimmedLine.match(/([\d.]+)%?/) || trimmedLine.match(/:\s*([\d.]+)/);
|
||||
if (channelMatch) {
|
||||
loraInfo.channelUtilization = parseFloat(channelMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Look for airtime utilization (various formats)
|
||||
if (trimmedLine.includes('airUtilTx') || trimmedLine.includes('Air time') || trimmedLine.includes('Airtime')) {
|
||||
const airtimeMatch = trimmedLine.match(/([\d.]+)%?/) || trimmedLine.match(/:\s*([\d.]+)/);
|
||||
if (airtimeMatch) {
|
||||
loraInfo.airtimeUtilization = parseFloat(airtimeMatch[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (infoError) {
|
||||
console.warn('Failed to get mesh info:', infoError.message);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn('LoRa info error:', error.message);
|
||||
loraInfo.connected = false;
|
||||
}
|
||||
|
||||
setTimeout(() => loraInfo = null, 60000)
|
||||
return loraInfo;
|
||||
}
|
||||
|
||||
// Main status generation function - matches frontend expectations
|
||||
async function generateStatus() {
|
||||
try {
|
||||
console.log('Generating fresh status...');
|
||||
|
||||
const [systemInfo, gpsInfo, networkInfo, loraInfo] = await Promise.all([
|
||||
getSystemInfo(),
|
||||
getGPSInfo(),
|
||||
getNetworkInfo(),
|
||||
getLoRaInfo()
|
||||
]);
|
||||
|
||||
// Format exactly as the frontend expects
|
||||
const status = {
|
||||
system: {
|
||||
temperature: systemInfo.temperature,
|
||||
load: systemInfo.load,
|
||||
cpu: systemInfo.cpu,
|
||||
memory: systemInfo.memory,
|
||||
storage: systemInfo.storage
|
||||
},
|
||||
gps: {
|
||||
fix: gpsInfo.fix,
|
||||
satellites: gpsInfo.satellites,
|
||||
latitude: gpsInfo.latitude,
|
||||
longitude: gpsInfo.longitude,
|
||||
altitude: gpsInfo.altitude
|
||||
},
|
||||
network: {
|
||||
wifi: networkInfo.wifi,
|
||||
ethernet: networkInfo.ethernet
|
||||
},
|
||||
lora: loraInfo,
|
||||
time: new Date().toISOString()
|
||||
};
|
||||
|
||||
return status;
|
||||
} catch (error) {
|
||||
console.error('Status generation error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/hostname', (req, res) => res.json({ hostname: environment.hostname }));
|
||||
|
||||
// Main status endpoint that matches frontend expectations
|
||||
router.get('/status', async (req, res) => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
|
||||
// Return cached status if within TTL
|
||||
if (statusCache && (now - lastUpdate) < CACHE_TTL) {
|
||||
console.log('Returning cached status');
|
||||
return res.json(statusCache);
|
||||
}
|
||||
|
||||
// Generate fresh status
|
||||
statusCache = await generateStatus();
|
||||
lastUpdate = now;
|
||||
|
||||
console.log('Status updated successfully');
|
||||
res.json(statusCache);
|
||||
} catch (error) {
|
||||
console.error('Status API error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to get system status',
|
||||
message: error.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Individual debug endpoints
|
||||
router.get('/system', async (req, res) => {
|
||||
try {
|
||||
const systemInfo = await getSystemInfo();
|
||||
res.json(systemInfo);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/gps', async (req, res) => {
|
||||
try {
|
||||
const gpsInfo = await getGPSInfo();
|
||||
res.json(gpsInfo);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/network', async (req, res) => {
|
||||
try {
|
||||
const networkInfo = await getNetworkInfo();
|
||||
res.json(networkInfo);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Add individual LoRa endpoint
|
||||
router.get('/lora', async (req, res) => {
|
||||
try {
|
||||
const loraInfo = await getLoRaInfo();
|
||||
res.json(loraInfo);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize GPS on startup
|
||||
initGPS().catch(err => console.warn('GPS initialization failed:', err.message));
|
||||
|
||||
export { router as statusRouter };
|
||||
Executable
Executable
+114
@@ -0,0 +1,114 @@
|
||||
# Maps
|
||||
|
||||
MRS comes with a self-hosted tile server to provide offline maps using [TileServer GL](https://github.com/maptiler/tileserver-gl).
|
||||
|
||||
While everything comes pre-configured this guide will walkthrough the setup + integration.
|
||||
|
||||
## Table of Contents
|
||||
- [MRS](../README.md)
|
||||
- [Maps](#maps)
|
||||
- [Usage]()
|
||||
- [Adding Maps]()
|
||||
- [Adding Contours]()
|
||||
|
||||
## Usage
|
||||
|
||||
The TileServer can be viewed at http://localhost:5000
|
||||
|
||||
Other applications can use the tileserver by configure the application to use:
|
||||
- **Raster**: http://localhost:8080/data/{id}/{z}/{x}/{y}.png
|
||||
- **Vector**: http://localhost:8080/data/{id}/{z}/{x}/{y}.pbf
|
||||
|
||||
|
||||
### Commands
|
||||
- **Setup:** `docker run -d --name maps --restart unless-stopped -p 5000:8080 -v /media/maps/tiles:/data -v /media/maps/styles:/styles maptiler/tileserver-gl:v4.11.0`
|
||||
- **Restart:** `docker restart maps`
|
||||
- **Start:** `docker start maps`
|
||||
- **Stop:** `docker stop maps`
|
||||
|
||||
## Adding Maps / Styles
|
||||
|
||||
TileServer GL works with both vector & raster map tiles.
|
||||
1. [Download](https://www.maptiler.com/on-prem-datasets/planet/) map tiles
|
||||
2. Upload tiles file to `/media/maps/tiles`
|
||||
3. Upload styles to `/media/maps/styles`
|
||||
4. Reboot TileServer GL: `docker restart maps`
|
||||
|
||||
## Adding Contours
|
||||
|
||||
All topographic maps I have found cost thousands of dollars. With that in mind, these instructions will compile our own topographic layer we can add to any tileset.
|
||||
|
||||
### 1. 📁 Download Topographic Data
|
||||
|
||||
Topography data sets can be downloaded from [OpenTopography](https://portal.opentopography.org/dataCatalog).
|
||||
|
||||
Follow the instructions to sign up & download **GEBCO Global Bathymetry and Topography**
|
||||
|
||||
### 2. 📦 Tooling Setup
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y gdal-bin build-essential sqlite3 libsqlite3-dev git wget unzip docker.io zlib1g-dev
|
||||
git clone https://github.com/mapbox/tippecanoe.git
|
||||
cd tippecanoe
|
||||
make -j$(nproc)
|
||||
sudo make install
|
||||
cd ..
|
||||
```
|
||||
|
||||
### 3. 🌄 Generate Global 50 m Contours
|
||||
|
||||
> This step is heavy, dont run on Pi — expect 60–90 GB raw GeoJSON before being compressed to ~2-4 GB
|
||||
|
||||
```bash
|
||||
gdal_contour -f GeoJSONSeq
|
||||
-a ele \ # elevation attribute
|
||||
-i 50 \ # interval (50 m)
|
||||
GEBCOIceTopo.vrt global_50.geojsonl
|
||||
|
||||
tippecanoe -o contours_50m.mbtiles \
|
||||
-Z0 -z12 \ # min/max zoom levels
|
||||
--drop-densest-as-needed \ # keep tiles small
|
||||
--extend-zooms-if-still-dropping \ # avoid data loss
|
||||
--layer=contour \ # layer name
|
||||
--read-parallel --reorder \ # faster + optimized
|
||||
global_50.geojsonl
|
||||
```
|
||||
|
||||
### 4. 🎨 Add contours to style
|
||||
|
||||
Create / Modify a style.json
|
||||
```json
|
||||
{
|
||||
"version": 8,
|
||||
"name": "Terrain",
|
||||
"sources": {
|
||||
...
|
||||
"contours": {
|
||||
"type": "vector",
|
||||
"url": "mbtiles://contours_50m.mbtiles"
|
||||
}
|
||||
},
|
||||
"layers": [
|
||||
...
|
||||
{
|
||||
"id": "contours-major",
|
||||
"type": "line",
|
||||
"source": "contours",
|
||||
"source-layer": "contour",
|
||||
"minzoom": 0,
|
||||
"maxzoom": 6,
|
||||
"filter": [">=", "ele", 1000],
|
||||
"paint": { "line-color": "#888", "line-width": 0.4 }
|
||||
},
|
||||
{
|
||||
"id": "contours-detailed",
|
||||
"type": "line",
|
||||
"source": "contours",
|
||||
"source-layer": "contour",
|
||||
"minzoom": 7,
|
||||
"paint": { "line-color": "#666", "line-width": 0.3 }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
# Setup
|
||||
|
||||
### Hardware
|
||||
- Raspberry Pi 5
|
||||
- 4 GB - No AI
|
||||
- 8 GB - Small AI
|
||||
- 16GB - Medium AI
|
||||
- SD card (recommended 500 GB - 1 TB)
|
||||
- Gutenberg Project (~80 GB)
|
||||
- Khan Academy (~180 GB)
|
||||
- Satellite Maps (150-300 GB)
|
||||
- Vector Maps (60 - 100 GB)
|
||||
- Wikipedia (~120 GB)
|
||||
- [Duel M.2 NVMe hat](https://www.amazon.ca/dp/B0D83784HS?ref=ppx_yo2ov_dt_b_fed_asin_title)
|
||||
- [M.2 A+E key to M key adapter](https://www.amazon.ca/dp/B0CP7F55S8?ref=ppx_yo2ov_dt_b_fed_asin_title)
|
||||
- [Intel AX210 WiFi modem](https://www.amazon.ca/dp/B0B39631G1?ref=ppx_yo2ov_dt_b_fed_asin_title)
|
||||
- [M.2 HaLow modem](https://www.seeedstudio.com/Wio-WM6180-Wi-Fi-HaLow-mini-PCIe-Module-p-6394.html?srsltid=AfmBOopb8Phtr-IBBvYRn5AB5NQNuGIK2tC3we4ceaA_0QiX1JD78-Qs)
|
||||
- BE-180 GPS Module or [BE-220 GPS Module](https://www.amazon.ca/Geekstory-Navigation-38400bps-Raspberry-Controller/dp/B0BV2MV2GN/ref=sr_1_16_sspa?crid=3N5GO07PGL65Y&dib=eyJ2IjoiMSJ9.Co3Uo8H4Xww9Sh693R4fJJokreDApWile1X8epOqTfZqvQbix_mZr566DdLs_cTuWwurRGQZHlt2nFhdAiW4SZZ1HvsdLlBsqUWOEF7gav47G8Z6xFNT97E1jQLuc0j-PuSRGgMPd0gktZenNz4PkQ.Jma35qmqkNfqByw4Rdk4N7H0eM4CILfcf67G4gV2CD4&dib_tag=se&keywords=gms%2Buart&qid=1758982047&sprefix=gms%2Buart%2Caps%2C84&sr=8-16-spons&sp_csd=d2lkZ2V0TmFtZT1zcF9idGY&th=1)
|
||||
- [Lora WisBlock Mini](https://www.amazon.ca/RAKwireless-WisBlock-Meshtastic-Starter-RAK19003/dp/B0DFMMTQZM/ref=sr_1_3?crid=QS5X4FYBC5H4&dib=eyJ2IjoiMSJ9.yzgzN2wMOGRfrwlfFeUwsCia4FERbPXPTBnq2WddC2An9Ni_Z2GAgGhquS96QFbSDzvlGu2X070zss9ggbEKGSLgrvRwvmF_mueX5aukwK_xB3S0VFJCsagWRIXdIIrX3k5cF0Seo_NoyuhmfD5wZkR9b0MInx5oRSrJ37Pd0BjERXpxZ3udlIJYf4mBgaGjNDrWc5rYu31deOGOdBfD54lbsrogePm4lb1nhz5Bs81wZTuCCDIm7V0QzO-2mq3Aj2_TLjY-F4F09C1zUm7_fYs9bGPEFmZ6N-syZFNoV6A.oUArviz-pLwgnF9wPrvf-MNKLIyQxwe0hy7uWhXqOVo&dib_tag=se&keywords=wisblock&qid=1758982932&s=electronics&sprefix=wisblock%2Celectronics%2C79&sr=1-3&th=1)
|
||||
- [E-ink display & momentary switch](https://www.amazon.ca/dp/B0D5DGZXG3?ref=ppx_yo2ov_dt_b_fed_asin_title)
|
||||
- Rotary Encoder
|
||||
- USB C 4-pin breakout + 22 AWG wire
|
||||
- 4x SMA connectors + pigtails 6"
|
||||
- *Optional: Power bank - Off grid power*
|
||||
- *Optional: Copper tape - Increases reception*
|
||||
- *Optional: [SDR](https://www.amazon.ca/dp/B0747LVW59/ref=sspa_dk_detail_1?psc=1&pd_rd_i=B0747LVW59&pd_rd_w=N68sx&content-id=amzn1.sym.516c2169-755e-413a-a38a-68230f4ab66f&pf_rd_p=516c2169-755e-413a-a38a-68230f4ab66f&pf_rd_r=Z9QJD2SRSYTG0HNFHKWP&pd_rd_wg=NBvG6&pd_rd_r=32aa78ce-63df-481d-bc40-f802b5dbd8f2&sp_csd=d2lkZ2V0TmFtZT1zcF9kZXRhaWw) - RF/ADS-B Monitoring*
|
||||
- *Optional: [Extra WiFi Dongle](https://www.amazon.ca/dp/B01GC8XH0S?ref=ppx_yo2ov_dt_b_fed_asin_title) - Wireless monitoring without sacrificing mesh or AP*
|
||||
- *Optional: [NRF24LU1 2.4GHz transceiver](https://www.amazon.ca/dp/B09GCLCWYL?ref=ppx_yo2ov_dt_b_fed_asin_title) - HID hacking*
|
||||
|
||||
|
||||
### Instructions
|
||||
1) Flash your wisblock with meshtastic: https://flasher.meshtastic.org/
|
||||
2) Flash SD card with Raspbian, ensure Hostname/User/Wifi/SSH is setup
|
||||
3) Configure OS/Kernal & reboot: `raspi-config`
|
||||
1) Update
|
||||
2) Interface Options > SSH > Enable
|
||||
3) Interface Options > VNC > Disable
|
||||
4) Interface Options > SPI > Enable
|
||||
5) Interface Options > Serial Port > Disable Login Shell > Enable Hardware
|
||||
6) Advanced > Wayland > X11
|
||||
7) Advanced > Expand Filesystem
|
||||
4) Run setup script: 'chmod +x setup.sh && ./setup.sh'
|
||||
5) Configure Guacamole
|
||||
1) Login: `guacadmin/guacadmin`
|
||||
2) guacadmin > settings
|
||||
3) Users > Create user & delete guacadmin
|
||||
4) Connections
|
||||
1) Desktop
|
||||
- Name: `Desktop`
|
||||
- Protocol: `RDP`
|
||||
- Network > Host: `127.0.0.1`
|
||||
- Network > Port: `3389`
|
||||
- Authentication > Security mode: `RDP Encryption`
|
||||
- Performance > Disable Graphics Pipeline Extension: `true`
|
||||
- *Optional: username / password*
|
||||
2) Terminal
|
||||
- Name: `Terminal`
|
||||
- Protocol: `SSH`
|
||||
- Network > Host: `127.0.0.1`
|
||||
- Network > Port: `22`
|
||||
- *Optional: username / password*
|
||||
6) Update filebrowser password
|
||||
7) Create open-webui admin account & allow registration
|
||||
8) Create element admin account
|
||||
9) Download [Kiwix](https://library.kiwix.com) zims
|
||||
|
||||
##
|
||||
|
||||
## TODO
|
||||
- Create a captive portal with onboarding, PWA, links to all tools
|
||||
- Create settings page for managing lora, mesh, wifi & AP
|
||||
- Create a proxy for the [kiwix library](https://library.kiwix.org) that downloads zim files straight to the archive
|
||||
- Also create a page for making custom zim files and uploading them straight to the archive
|
||||
```bash
|
||||
docker run --rm -it -v $(pwd)/output:/output openzim/zimit https://example.com mysite
|
||||
```
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"options": {
|
||||
"paths": {
|
||||
"root": "/data",
|
||||
"styles": "/styles"
|
||||
}
|
||||
},
|
||||
"styles": {
|
||||
"dark": { "style": "/styles/dark.json" },
|
||||
"light": { "style": "/styles/light.json" }
|
||||
},
|
||||
"data": {
|
||||
"contours": { "mbtiles": "/data/contours_50m.mbtiles" },
|
||||
"osm": { "mbtiles": "/data/osm.mbtiles" },
|
||||
"satellite": { "mbtiles": "/data/satellite-2017.mbtiles" }
|
||||
}
|
||||
}
|
||||
Executable
+381
@@ -0,0 +1,381 @@
|
||||
{
|
||||
"version": 8,
|
||||
"name": "Dark Topographic",
|
||||
"sources": {
|
||||
"osm": {
|
||||
"type": "vector",
|
||||
"url": "mbtiles://osm"
|
||||
},
|
||||
"contours": {
|
||||
"type": "vector",
|
||||
"url": "mbtiles://contours"
|
||||
}
|
||||
},
|
||||
"glyphs": "/fonts/{fontstack}/{range}.pbf",
|
||||
"layers": [
|
||||
{
|
||||
"id": "background",
|
||||
"type": "background",
|
||||
"paint": { "background-color": "rgb(12,12,12)" }
|
||||
},
|
||||
{
|
||||
"id": "water",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "water",
|
||||
"paint": { "fill-color": "rgb(27,27,29)" }
|
||||
},
|
||||
{
|
||||
"id": "residential",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "landuse",
|
||||
"filter": ["==", "class", "residential"],
|
||||
"paint": { "fill-color": "hsl(0,2%,5%)", "fill-opacity": 0.4 }
|
||||
},
|
||||
{
|
||||
"id": "landcover-wood",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "landcover",
|
||||
"filter": ["==", "class", "wood"],
|
||||
"paint": { "fill-color": "rgb(170,170,170)", "fill-opacity": 0.075 }
|
||||
},
|
||||
{
|
||||
"id": "landcover-wetland",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "landcover",
|
||||
"filter": ["==", "class", "wetland"],
|
||||
"paint": { "fill-color": "rgb(150,150,150)", "fill-opacity": 0.075 }
|
||||
},
|
||||
{
|
||||
"id": "parks",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "landuse",
|
||||
"filter": ["==", "class", "park"],
|
||||
"paint": {
|
||||
"fill-color": "rgb(0,180,0)",
|
||||
"fill-opacity": 0.075,
|
||||
"fill-outline-color": "rgb(0,120,0)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "roads-small",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["in", "class", "residential", "service", "track", "path"],
|
||||
"minzoom": 5,
|
||||
"paint": {
|
||||
"line-color": "#1f1f1f",
|
||||
"line-width": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
5, 0.15,
|
||||
7, 0.25,
|
||||
12, 0.6,
|
||||
16, 1
|
||||
],
|
||||
"line-opacity": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
5, 0.1,
|
||||
7, 0.2,
|
||||
12, 0.6
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "roads-local",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["==", "class", "minor"],
|
||||
"minzoom": 6,
|
||||
"paint": {
|
||||
"line-color": "#2a2a2a",
|
||||
"line-width": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
6, 0.15,
|
||||
8, 0.3,
|
||||
12, 0.8,
|
||||
16, 1.2
|
||||
],
|
||||
"line-opacity": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
6, 0.1,
|
||||
8, 0.3,
|
||||
12, 0.6
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "roads-primary",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["in", "class", "primary", "secondary"],
|
||||
"minzoom": 7,
|
||||
"paint": {
|
||||
"line-color": "#333333",
|
||||
"line-width": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
7, 0.3,
|
||||
12, 1.5,
|
||||
16, 3
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "roads-motorway",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["in", "class", "motorway", "trunk"],
|
||||
"minzoom": 6,
|
||||
"paint": {
|
||||
"line-color": "#555555",
|
||||
"line-width": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
6, 0.5,
|
||||
10, 2,
|
||||
14, 5
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "boundary-country",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "boundary",
|
||||
"filter": ["all", ["==", "admin_level", 2], ["!=", "maritime", 1]],
|
||||
"paint": {
|
||||
"line-color": "#888",
|
||||
"line-width": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
0, 0.5,
|
||||
4, 1.5,
|
||||
6, 2
|
||||
],
|
||||
"line-opacity": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
0, 0.3,
|
||||
3, 0.6,
|
||||
5, 0.8
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "boundary-state",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "boundary",
|
||||
"filter": ["all", ["==", "admin_level", 4], ["!=", "maritime", 1]],
|
||||
"minzoom": 2,
|
||||
"paint": {
|
||||
"line-color": "#666",
|
||||
"line-width": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
2, 0.5,
|
||||
5, 1,
|
||||
8, 2
|
||||
],
|
||||
"line-opacity": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
2, 0,
|
||||
4, 0.5,
|
||||
6, 0.8
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "boundary-local",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "boundary",
|
||||
"filter": ["all", ["==", "admin_level", 6], ["!=", "maritime", 1]],
|
||||
"minzoom": 6,
|
||||
"paint": {
|
||||
"line-color": "#444",
|
||||
"line-width": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
6, 0.5,
|
||||
10, 1.2
|
||||
],
|
||||
"line-opacity": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
6, 0,
|
||||
8, 0.5,
|
||||
12, 0.8
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "buildings",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "building",
|
||||
"minzoom": 14,
|
||||
"paint": {
|
||||
"fill-color": "#222222",
|
||||
"fill-opacity": 0.7
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "places",
|
||||
"type": "symbol",
|
||||
"source": "osm",
|
||||
"source-layer": "place",
|
||||
"minzoom": 2,
|
||||
"layout": {
|
||||
"text-size": 12,
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-field": ["coalesce", ["get", "name:en"], ["get", "name_en"], ["get", "name"]]
|
||||
},
|
||||
"filter": ["!=", "class", "continent"],
|
||||
"paint": {
|
||||
"text-color": "rgba(200,200,200,0.4)",
|
||||
"text-halo-color": "rgba(0,0,0,0.6)",
|
||||
"text-halo-width": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-labels",
|
||||
"type": "symbol",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation_name",
|
||||
"minzoom": 13,
|
||||
"layout": {
|
||||
"symbol-placement": "line",
|
||||
"text-field": ["coalesce", ["get", "name:en"], ["get", "name"]],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
13, 10,
|
||||
16, 14
|
||||
]
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "rgba(200,200,200,0.4)",
|
||||
"text-halo-color": "rgba(0,0,0,0.6)",
|
||||
"text-halo-width": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "poi-labels",
|
||||
"type": "symbol",
|
||||
"source": "osm",
|
||||
"source-layer": "poi",
|
||||
"minzoom": 14,
|
||||
"layout": {
|
||||
"text-field": ["coalesce", ["get", "name:en"], ["get", "name_en"], ["get", "name"]],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
14, 9,
|
||||
18, 13
|
||||
]
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "rgba(200,200,200,0.4)",
|
||||
"text-halo-color": "rgba(0,0,0,0.6)",
|
||||
"text-halo-width": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "airport-labels",
|
||||
"type": "symbol",
|
||||
"source": "osm",
|
||||
"source-layer": "aeroway",
|
||||
"minzoom": 14,
|
||||
"layout": {
|
||||
"text-field": ["coalesce", ["get", "name:en"], ["get", "name"]],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": 12
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "rgba(200,200,200,0.4)",
|
||||
"text-halo-color": "rgba(0,0,0,0.6)",
|
||||
"text-halo-width": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rail-labels",
|
||||
"type": "symbol",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["==", "class", "rail"],
|
||||
"minzoom": 14,
|
||||
"layout": {
|
||||
"symbol-placement": "line",
|
||||
"text-field": ["coalesce", ["get", "name:en"], ["get", "name"]],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": 11
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "rgba(200,200,200,0.4)",
|
||||
"text-halo-color": "rgba(0,0,0,0.6)",
|
||||
"text-halo-width": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "natural-labels",
|
||||
"type": "symbol",
|
||||
"source": "osm",
|
||||
"source-layer": "natural",
|
||||
"minzoom": 14,
|
||||
"layout": {
|
||||
"text-field": ["coalesce", ["get", "name:en"], ["get", "name"]],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": 11
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "rgba(200,200,200,0.4)",
|
||||
"text-halo-color": "rgba(0,0,0,0.6)",
|
||||
"text-halo-width": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "house-numbers",
|
||||
"type": "symbol",
|
||||
"source": "osm",
|
||||
"source-layer": "housenumber",
|
||||
"minzoom": 15,
|
||||
"layout": {
|
||||
"text-field": "{housenumber}",
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
15, 10,
|
||||
29, 14
|
||||
]
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "rgba(200,200,200,0.2)",
|
||||
"text-halo-color": "rgba(0,0,0,0.6)",
|
||||
"text-halo-width": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "contours-line",
|
||||
"type": "line",
|
||||
"source": "contours",
|
||||
"source-layer": "contour",
|
||||
"minzoom": 10,
|
||||
"paint": {
|
||||
"line-color": "#555555",
|
||||
"line-opacity": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
10, 0.2,
|
||||
14, 0.6
|
||||
],
|
||||
"line-width": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
10, 0.5,
|
||||
14, 1.5
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"id": "dark-matter-slim"
|
||||
}
|
||||
Executable
+368
@@ -0,0 +1,368 @@
|
||||
{
|
||||
"version": 8,
|
||||
"name": "Light Topographic",
|
||||
"sources": {
|
||||
"osm": {
|
||||
"type": "vector",
|
||||
"url": "mbtiles://osm"
|
||||
},
|
||||
"contours": {
|
||||
"type": "vector",
|
||||
"url": "mbtiles://contours"
|
||||
}
|
||||
},
|
||||
"glyphs": "/fonts/{fontstack}/{range}.pbf",
|
||||
"layers": [
|
||||
{
|
||||
"id": "background",
|
||||
"type": "background",
|
||||
"paint": { "background-color": "hsl(47, 26%, 88%)" }
|
||||
},
|
||||
{
|
||||
"id": "water",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "water",
|
||||
"paint": { "fill-color": "hsl(205, 56%, 73%)" }
|
||||
},
|
||||
{
|
||||
"id": "residential",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "landuse",
|
||||
"filter": ["==", "class", "residential"],
|
||||
"paint": { "fill-color": "hsl(47, 13%, 86%)", "fill-opacity": 0.7 }
|
||||
},
|
||||
{
|
||||
"id": "landcover-wood",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "landcover",
|
||||
"filter": ["==", "class", "wood"],
|
||||
"paint": {
|
||||
"fill-color": "hsl(82, 46%, 72%)",
|
||||
"fill-opacity": { "base": 1, "stops": [[8, 0.6], [22, 1]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "landcover-wetland",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "landcover",
|
||||
"filter": ["==", "class", "wetland"],
|
||||
"paint": { "fill-color": "hsl(82, 46%, 72%)", "fill-opacity": 0.45 }
|
||||
},
|
||||
{
|
||||
"id": "parks",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "landuse",
|
||||
"filter": ["==", "class", "park"],
|
||||
"paint": {
|
||||
"fill-color": "#E1EBB0",
|
||||
"fill-opacity": { "base": 1, "stops": [[5, 0], [9, 0.75]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "roads-small",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["in", "class", "residential", "service", "track", "path"],
|
||||
"minzoom": 5,
|
||||
"paint": {
|
||||
"line-color": "hsl(0, 0%, 97%)",
|
||||
"line-width": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
5, 0.15,
|
||||
7, 0.25,
|
||||
12, 0.6,
|
||||
16, 1
|
||||
],
|
||||
"line-opacity": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
5, 0.1,
|
||||
7, 0.2,
|
||||
12, 0.6
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "roads-local",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["==", "class", "minor"],
|
||||
"minzoom": 6,
|
||||
"paint": {
|
||||
"line-color": "hsl(0, 0%, 97%)",
|
||||
"line-width": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
6, 0.15,
|
||||
8, 0.3,
|
||||
12, 0.8,
|
||||
16, 1.2
|
||||
],
|
||||
"line-opacity": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
6, 0.1,
|
||||
8, 0.3,
|
||||
12, 0.6
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "roads-primary",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["in", "class", "primary", "secondary"],
|
||||
"minzoom": 7,
|
||||
"paint": {
|
||||
"line-color": "#fff",
|
||||
"line-width": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
7, 0.3,
|
||||
12, 1.5,
|
||||
16, 3
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "roads-motorway",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["in", "class", "motorway", "trunk"],
|
||||
"minzoom": 6,
|
||||
"paint": {
|
||||
"line-color": "hsl(0, 0%, 100%)",
|
||||
"line-width": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
6, 0.5,
|
||||
10, 2,
|
||||
14, 5
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "boundary-country",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "boundary",
|
||||
"filter": ["all", ["==", "admin_level", 2], ["!=", "maritime", 1]],
|
||||
"paint": {
|
||||
"line-color": "hsl(0, 0%, 60%)",
|
||||
"line-width": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
0, 0.5,
|
||||
4, 1.5,
|
||||
6, 2
|
||||
],
|
||||
"line-opacity": 0.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "boundary-state",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "boundary",
|
||||
"filter": ["all", ["==", "admin_level", 4], ["!=", "maritime", 1]],
|
||||
"minzoom": 2,
|
||||
"paint": {
|
||||
"line-color": "hsla(0, 0%, 60%, 0.5)",
|
||||
"line-dasharray": [2, 1],
|
||||
"line-width": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
2, 0.5,
|
||||
5, 1,
|
||||
8, 2
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "boundary-local",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "boundary",
|
||||
"filter": ["all", ["==", "admin_level", 6], ["!=", "maritime", 1]],
|
||||
"minzoom": 6,
|
||||
"paint": {
|
||||
"line-color": "#bbbbbb",
|
||||
"line-width": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
6, 0.5,
|
||||
10, 1.2
|
||||
],
|
||||
"line-opacity": 0.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "buildings",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "building",
|
||||
"minzoom": 14,
|
||||
"paint": {
|
||||
"fill-color": "rgba(222, 211, 190, 1)",
|
||||
"fill-opacity": { "base": 1, "stops": [[13, 0], [15, 1]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "places",
|
||||
"type": "symbol",
|
||||
"source": "osm",
|
||||
"source-layer": "place",
|
||||
"minzoom": 2,
|
||||
"filter": ["!=", "class", "continent"],
|
||||
"layout": {
|
||||
"text-size": 12,
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-field": ["coalesce", ["get", "name:en"], ["get", "name_en"], ["get", "name"]]
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#444",
|
||||
"text-halo-color": "#ffffff",
|
||||
"text-halo-width": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-labels",
|
||||
"type": "symbol",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation_name",
|
||||
"minzoom": 13,
|
||||
"layout": {
|
||||
"symbol-placement": "line",
|
||||
"text-field": ["coalesce", ["get", "name:en"], ["get", "name"]],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
13, 10,
|
||||
16, 14
|
||||
]
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#444",
|
||||
"text-halo-color": "#ffffff",
|
||||
"text-halo-width": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "poi-labels",
|
||||
"type": "symbol",
|
||||
"source": "osm",
|
||||
"source-layer": "poi",
|
||||
"minzoom": 14,
|
||||
"layout": {
|
||||
"text-field": ["coalesce", ["get", "name:en"], ["get", "name_en"], ["get", "name"]],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
14, 9,
|
||||
18, 13
|
||||
]
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "rgba(100,100,100,0.6)",
|
||||
"text-halo-color": "rgba(255,255,255,0.6)",
|
||||
"text-halo-width": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "airport-labels",
|
||||
"type": "symbol",
|
||||
"source": "osm",
|
||||
"source-layer": "aeroway",
|
||||
"minzoom": 14,
|
||||
"layout": {
|
||||
"text-field": ["coalesce", ["get", "name:en"], ["get", "name"]],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": 12
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "rgba(100,100,100,0.6)",
|
||||
"text-halo-color": "rgba(255,255,255,0.6)",
|
||||
"text-halo-width": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rail-labels",
|
||||
"type": "symbol",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["==", "class", "rail"],
|
||||
"minzoom": 14,
|
||||
"layout": {
|
||||
"symbol-placement": "line",
|
||||
"text-field": ["coalesce", ["get", "name:en"], ["get", "name"]],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": 11
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "rgba(100,100,100,0.6)",
|
||||
"text-halo-color": "rgba(255,255,255,0.6)",
|
||||
"text-halo-width": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "natural-labels",
|
||||
"type": "symbol",
|
||||
"source": "osm",
|
||||
"source-layer": "natural",
|
||||
"minzoom": 14,
|
||||
"layout": {
|
||||
"text-field": ["coalesce", ["get", "name:en"], ["get", "name"]],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": 11
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "rgba(100,100,100,0.6)",
|
||||
"text-halo-color": "rgba(255,255,255,0.6)",
|
||||
"text-halo-width": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "house-numbers",
|
||||
"type": "symbol",
|
||||
"source": "osm",
|
||||
"source-layer": "housenumber",
|
||||
"minzoom": 15,
|
||||
"layout": {
|
||||
"text-field": "{housenumber}",
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
15, 10,
|
||||
29, 14
|
||||
]
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "rgba(100,100,100,0.5)",
|
||||
"text-halo-color": "rgba(255,255,255,0.6)",
|
||||
"text-halo-width": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "contours-line",
|
||||
"type": "line",
|
||||
"source": "contours",
|
||||
"source-layer": "contour",
|
||||
"minzoom": 10,
|
||||
"paint": {
|
||||
"line-color": "#999",
|
||||
"line-opacity": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
10, 0.2,
|
||||
14, 0.5
|
||||
],
|
||||
"line-width": [
|
||||
"interpolate", ["linear"], ["zoom"],
|
||||
10, 0.5,
|
||||
14, 1.2
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"id": "light-matter-basic"
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Executable
+939
@@ -0,0 +1,939 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>MRS Dashboard</title>
|
||||
<link rel="icon" href="/favicon.png">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||
min-height: 100vh;
|
||||
color: #fff;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
grid-template-areas: "logo status clocks";
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
grid-area: logo;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.logo-section img {
|
||||
height: 3rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05rem;
|
||||
}
|
||||
|
||||
.hostname {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
grid-area: clocks;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.network-status-wrapper {
|
||||
grid-area: status;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.network-status {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.network-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.4rem;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.network-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.network-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
stroke-width: 2;
|
||||
fill: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.expand-toggle {
|
||||
cursor: pointer;
|
||||
padding: 0.4rem;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.expand-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
stroke: #94a3b8;
|
||||
stroke-width: 2;
|
||||
fill: none;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.expand-icon.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.expanded-status {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease;
|
||||
}
|
||||
|
||||
.expanded-status.visible {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.status-tiles {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.status-tiles::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.status-tiles::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.status-tiles::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.status-tiles::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.status-tile {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
justify-content: space-between;
|
||||
flex: 0 0 auto;
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.tile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.tile-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.tile-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05rem;
|
||||
}
|
||||
|
||||
.tile-info {
|
||||
font-size: 0.85rem;
|
||||
color: #94a3b8;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.tile-detail {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.network-item.disconnected .network-icon,
|
||||
.status-tile.disconnected .tile-icon {
|
||||
stroke: #64748b;
|
||||
}
|
||||
|
||||
.status-tile.disconnected .tile-name {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.status-tile.connected {
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.network-item.connected .network-icon,
|
||||
.status-tile.connected .tile-icon {
|
||||
stroke: #10b981;
|
||||
}
|
||||
|
||||
.status-tile.connected .tile-name {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.time-display {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.time-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.time-label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05rem;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.time-value {
|
||||
font-weight: 600;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.time-date {
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 1rem 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-bar:focus-within {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
stroke: #64748b;
|
||||
stroke-width: 2;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.apps-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.app-item {
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.second-action {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px;
|
||||
border-radius: 50%;
|
||||
background: #5a6b63;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.second-action:hover {
|
||||
background: #6a7b73;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.second-action:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.second-action svg {
|
||||
stroke: white;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.app-badge svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
stroke: #fff;
|
||||
stroke-width: 2;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.app-icon-wrapper {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.app-item:hover .app-icon-wrapper {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.app-item:active .app-icon-wrapper {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
#temp {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
stroke: #fff;
|
||||
stroke-width: 1.5;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.tile-wide {
|
||||
width: 360px;
|
||||
}
|
||||
|
||||
.system-metrics {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.system-metric {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: #cdd5e0;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.metric-label span {
|
||||
font-size: 0.7rem;
|
||||
color: #94a3b8;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex-grow: 1;
|
||||
height: 14px;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background: #10b981;
|
||||
border-radius: 6px;
|
||||
transition: width 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.progress-bar-text {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #fff;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Responsive breakpoints */
|
||||
@media (max-width: 800px) {
|
||||
.header-content {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-areas:
|
||||
"logo clocks"
|
||||
"status status";
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.network-status-wrapper {
|
||||
justify-self: center;
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.header-content {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
"logo"
|
||||
"clocks"
|
||||
"status";
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.time-display {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.time-block {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.network-status-wrapper {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-content">
|
||||
<div class="logo-section">
|
||||
<img src="/favicon.png" alt="Logo">
|
||||
<div>
|
||||
<div class="logo">DASHBOARD</div>
|
||||
<div class="hostname">Hostname</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-info">
|
||||
<div class="time-display">
|
||||
<div class="time-block">
|
||||
<div class="time-label">Local</div>
|
||||
<div class="time-value" id="local-time">00:00:00</div>
|
||||
<div class="time-date" id="local-date">Jan 1, 2025</div>
|
||||
</div>
|
||||
<div class="time-block">
|
||||
<div class="time-label">Zulu</div>
|
||||
<div class="time-value" id="zulu-time">00:00:00</div>
|
||||
<div class="time-date" id="zulu-date">Jan 1, 2025</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="network-status-wrapper">
|
||||
<div class="network-status">
|
||||
<div class="network-item disconnected" id="gps-status">
|
||||
<svg class="network-icon" viewBox="0 0 24 24">
|
||||
<g transform="translate(2,2) rotate(-45 12 12)">
|
||||
<rect x="9.5" y="1" width="5" height="8.5" rx="1" ry="1" fill="transparent"/>
|
||||
<rect x="1" y="6" width="6" height="4"/>
|
||||
<rect x="17" y="6" width="6" height="4"/>
|
||||
<line x1="1" y1="8" x2="23" y2="8"/>
|
||||
<defs>
|
||||
<clipPath id="halfCircle">
|
||||
<rect x="0" y="5" width="100%" height="42%" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<circle cx="12" cy="17.5" r="6px" clip-path="url(#halfCircle)" fill="transparent" />
|
||||
<line x1="12" y1="12" x2="12" y2="17" stroke-linecap="round" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="network-item connected" id="lora-status">
|
||||
<svg class="network-icon" viewBox="0 0 24 24">
|
||||
<line x1="0" y1="18" x2="8" y2="6"/>
|
||||
<line x1="8" y1="18" x2="16" y2="6"/>
|
||||
<line x1="16" y1="6" x2="24" y2="18"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="network-item connected" id="mesh-status">
|
||||
<svg class="network-icon" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="5" cy="5" r="2" />
|
||||
<line x1="6" y1="6" x2="15" y2="4" />
|
||||
<line x1="6" y1="6" x2="20" y2="12" />
|
||||
<line x1="6" y1="6" x2="13.5" y2="19" />
|
||||
<line x1="6" y1="6" x2="5" y2="15" />
|
||||
<circle cx="15.5" cy="3" r="2" />
|
||||
<line x1="15" y1="4" x2="20" y2="12" />
|
||||
<line x1="15" y1="4" x2="13.5" y2="19" />
|
||||
<line x1="15" y1="4" x2="5" y2="15" />
|
||||
<circle cx="21" cy="12" r="2" />
|
||||
<line x1="20" y1="12" x2="13.5" y2="19" />
|
||||
<circle cx="14" cy="20.5" r="2" />
|
||||
<line x1="13.5" y1="19" x2="5" y2="15" />
|
||||
<circle cx="4" cy="16" r="2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="network-item connected" id="wifi-status">
|
||||
<svg class="network-icon" viewBox="0 0 24 24">
|
||||
<path d="M5 12.55a11 11 0 0 1 14.08 0"/>
|
||||
<path d="M1.42 9a16 16 0 0 1 21.16 0"/>
|
||||
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"/>
|
||||
<line x1="12" y1="20" x2="12.01" y2="20"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="network-item disconnected" id="ethernet-status">
|
||||
<svg class="network-icon" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="5" y="9" width="14" height="10" rx="1" ry="1"/>
|
||||
<rect x="9" y="5" width="6" height="4" rx="0.5" ry="0.5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="expand-toggle" id="expand-toggle">
|
||||
<svg class="expand-icon" id="expand-icon" viewBox="0 0 24 24">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="expanded-status" id="expanded-status">
|
||||
<div class="status-tiles">
|
||||
<div class="status-tile connected tile-wide" id="system-tile">
|
||||
<div class="tile-header">
|
||||
<svg class="tile-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
|
||||
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
|
||||
<line x1="6" y1="6" x2="6.01" y2="6"></line>
|
||||
<line x1="10" y1="6" x2="10.01" y2="6"></line>
|
||||
<line x1="6" y1="18" x2="6.01" y2="18"></line>
|
||||
<line x1="10" y1="18" x2="10.01" y2="18"></line>
|
||||
</svg>
|
||||
<div class="tile-name">System <span id="temp">?°C</span></div>
|
||||
</div>
|
||||
<div class="system-metrics">
|
||||
<div class="system-metric">
|
||||
<div class="metric-label">CPU <span id="load">- Load</span></div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar-fill" id="cpu-usage-fill"></div>
|
||||
<div class="progress-bar-text" id="cpu-usage-text">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="system-metric">
|
||||
<div class="metric-label">Memory</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar-fill" id="memory-usage-fill"></div>
|
||||
<div class="progress-bar-text" id="memory-usage-text">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="system-metric">
|
||||
<div class="metric-label">Disk</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar-fill" id="disk-usage-fill"></div>
|
||||
<div class="progress-bar-text" id="disk-usage-text">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-tile disconnected" id="gps-tile">
|
||||
<div class="tile-header">
|
||||
<svg class="tile-icon" viewBox="0 0 24 24">
|
||||
<g transform="translate(2,2) rotate(-45 12 12)">
|
||||
<rect x="9.5" y="1" width="5" height="8.5" rx="1" ry="1" fill="transparent" />
|
||||
<rect x="1" y="6" width="6" height="4"/>
|
||||
<rect x="17" y="6" width="6" height="4"/>
|
||||
<line x1="1" y1="8" x2="23" y2="8"/>
|
||||
<defs>
|
||||
<clipPath id="halfCircle">
|
||||
<rect x="0" y="5" width="100%" height="42%" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<circle cx="12" cy="17.5" r="6px" clip-path="url(#halfCircle)" fill="transparent" />
|
||||
<line x1="12" y1="12" x2="12" y2="17" stroke-linecap="round" />
|
||||
</g>
|
||||
</svg>
|
||||
<div class="tile-name">GPS</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="tile-info">39.2000, -49.0000</div>
|
||||
<div class="tile-detail">No Fix</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-tile disconnected" id="lora-tile">
|
||||
<div class="tile-header">
|
||||
<svg class="tile-icon" viewBox="0 0 24 24">
|
||||
<line x1="0" y1="18" x2="8" y2="6"/>
|
||||
<line x1="8" y1="18" x2="16" y2="6"/>
|
||||
<line x1="16" y1="6" x2="24" y2="18"/>
|
||||
</svg>
|
||||
<div class="tile-name">LoRa</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="tile-info">0 / 0 Online</div>
|
||||
<div class="tile-detail">Ch 0% Air 0%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-tile connected" id="mesh-tile">
|
||||
<div class="tile-header">
|
||||
<svg class="tile-icon" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="5" cy="5" r="2" />
|
||||
<line x1="6" y1="6" x2="15" y2="4" />
|
||||
<line x1="6" y1="6" x2="20" y2="12" />
|
||||
<line x1="6" y1="6" x2="13.5" y2="19" />
|
||||
<line x1="6" y1="6" x2="5" y2="15" />
|
||||
<circle cx="15.5" cy="3" r="2" />
|
||||
<line x1="15" y1="4" x2="20" y2="12" />
|
||||
<line x1="15" y1="4" x2="13.5" y2="19" />
|
||||
<line x1="15" y1="4" x2="5" y2="15" />
|
||||
<circle cx="21" cy="12" r="2" />
|
||||
<line x1="20" y1="12" x2="13.5" y2="19" />
|
||||
<circle cx="14" cy="20.5" r="2" />
|
||||
<line x1="13.5" y1="19" x2="5" y2="15" />
|
||||
<circle cx="4" cy="16" r="2" />
|
||||
</svg>
|
||||
<div class="tile-name">Mesh</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="tile-info">Connected</div>
|
||||
<div class="tile-detail">900 Mhz -45 dBm</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-tile connected" id="wifi-tile">
|
||||
<div class="tile-header">
|
||||
<svg class="tile-icon" viewBox="0 0 24 24">
|
||||
<path d="M5 12.55a11 11 0 0 1 14.08 0"/>
|
||||
<path d="M1.42 9a16 16 0 0 1 21.16 0"/>
|
||||
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"/>
|
||||
<line x1="12" y1="20" x2="12.01" y2="20"/>
|
||||
</svg>
|
||||
<div class="tile-name">WiFi</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="tile-info">Access Point</div>
|
||||
<div class="tile-detail">SSID: Hostname</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-tile disconnected" id="ethernet-tile">
|
||||
<div class="tile-header">
|
||||
<svg class="tile-icon" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="5" y="9" width="14" height="10" rx="1" ry="1"/>
|
||||
<rect x="9" y="5" width="6" height="4" rx="0.5" ry="0.5"/>
|
||||
</svg>
|
||||
<div class="tile-name">Ethernet</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="tile-info">Disconnected</div>
|
||||
<div class="tile-detail">No cable detected</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="search-bar">
|
||||
<svg class="search-icon" viewBox="0 0 24 24">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.35-4.35"/>
|
||||
</svg>
|
||||
<input type="text" class="search-input" placeholder="Search apps..." id="search-input">
|
||||
</div>
|
||||
|
||||
<div class="apps-grid" id="apps-grid">
|
||||
<a href=":1000" class="app-item" data-name="AI Assistant">
|
||||
<div class="app-icon-wrapper">
|
||||
<svg class="app-icon" viewBox="0 0 24 24">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2" ry="2"/>
|
||||
<line x1="2" y1="9" x2="6" y2="9"/>
|
||||
<line x1="2" y1="12" x2="6" y2="12"/>
|
||||
<line x1="2" y1="15" x2="6" y2="15"/>
|
||||
<line x1="18" y1="9" x2="22" y2="9"/>
|
||||
<line x1="18" y1="12" x2="22" y2="12"/>
|
||||
<line x1="18" y1="15" x2="22" y2="15"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="app-name">AI Assistant</div>
|
||||
</a>
|
||||
|
||||
<a href=":1100" class="app-item" data-name="ATAK">
|
||||
<div class="app-icon-wrapper">
|
||||
<svg class="app-icon" viewBox="0 0 24 24">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
<div class="second-action">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
|
||||
<line x1="12" y1="0" x2="12" y2="24" />
|
||||
<line x1="4" y1="11" x2="12" y2="21" />
|
||||
<line x1="20" y1="11" x2="12" y2="21" />
|
||||
<line x1="1" y1="22" x2="23" y2="22" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-name">ATAK</div>
|
||||
</a>
|
||||
|
||||
<a href=":1200" class="app-item" data-name="File Browser">
|
||||
<div class="app-icon-wrapper">
|
||||
<svg class="app-icon" viewBox="0 0 24 24">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="app-name">File Browser</div>
|
||||
</a>
|
||||
|
||||
<a href=":1300" class="app-item" data-name="Library">
|
||||
<div class="app-icon-wrapper">
|
||||
<svg class="app-icon" viewBox="0 0 24 24">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
|
||||
</svg>
|
||||
<div class="second-action" onclick="openKiwixm(event)">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
|
||||
<circle cx="12" cy="12" r="6.5" stroke-width="5" />
|
||||
<line x1="12" y1="7" x2="12" y2=".5" />
|
||||
<line x1="12" y1="17" x2="12" y2="23.5" />
|
||||
<line x1="0.5" y1="12" x2="7" y2="12" />
|
||||
<line x1="17" y1="12" x2="23.5" y2="12" />
|
||||
<line x1="15" y1="15" x2="20" y2="20" />
|
||||
<line x1="4" y1="4" x2="9" y2="9" />
|
||||
<line x1="20" y1="4" x2="15" y2="9" />
|
||||
<line x1="4" y1="20" x2="9" y2="15" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-name">Library</div>
|
||||
</a>
|
||||
|
||||
<a href=":1400" class="app-item" data-name="Maps">
|
||||
<div class="app-icon-wrapper">
|
||||
<svg class="app-icon" viewBox="0 0 24 24">
|
||||
<path d="M3 7l6-3 6 3 6-3v13l-6 3-6-3-6 3z"/>
|
||||
<line x1="9" y1="4" x2="9" y2="17"/>
|
||||
<line x1="15" y1="7" x2="15" y2="20"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="app-name">Maps</div>
|
||||
</a>
|
||||
|
||||
<a href=":1500" class="app-item" data-name="Meshtastic">
|
||||
<div class="app-icon-wrapper">
|
||||
<svg class="app-icon" viewBox="0 0 24 24">
|
||||
<line x1="0" y1="18" x2="8" y2="6"/>
|
||||
<line x1="8" y1="18" x2="16" y2="6"/>
|
||||
<line x1="16" y1="6" x2="24" y2="18"/>
|
||||
</svg>
|
||||
<div class="second-action">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
|
||||
<line x1="12" y1="0" x2="12" y2="24" />
|
||||
<line x1="4" y1="11" x2="12" y2="21" />
|
||||
<line x1="20" y1="11" x2="12" y2="21" />
|
||||
<line x1="1" y1="22" x2="23" y2="22" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-name">Meshtastic</div>
|
||||
</a>
|
||||
|
||||
<a href=":1600" class="app-item" data-name="Messages">
|
||||
<div class="app-icon-wrapper">
|
||||
<svg class="app-icon" viewBox="0 0 24 24">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
<div class="second-action">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
|
||||
<line x1="12" y1="0" x2="12" y2="24" />
|
||||
<line x1="4" y1="11" x2="12" y2="21" />
|
||||
<line x1="20" y1="11" x2="12" y2="21" />
|
||||
<line x1="1" y1="22" x2="23" y2="22" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-name">Messages</div>
|
||||
</a>
|
||||
|
||||
<a href=":1700" class="app-item" data-name="Pentesting">
|
||||
<div class="app-icon-wrapper">
|
||||
<svg class="app-icon" viewBox="0 0 24 24">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="app-name">Pentesting</div>
|
||||
</a>
|
||||
|
||||
<a href=":1800" class="app-item" data-name="Remote Desktop">
|
||||
<div class="app-icon-wrapper">
|
||||
<svg class="app-icon" viewBox="0 0 24 24">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="app-name">Remote Desktop</div>
|
||||
</a>
|
||||
|
||||
<a href=":1900" class="app-item" data-name="Software Defined Radio">
|
||||
<div class="app-icon-wrapper">
|
||||
<svg class="app-icon" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="1.5"/>
|
||||
<path d="M16.24 7.76a6 6 0 0 1 0 8.49"/>
|
||||
<path d="M7.76 16.24a6 6 0 0 1 0-8.49"/>
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
|
||||
<path d="M4.93 19.07a10 10 0 0 1 0-14.14"/>
|
||||
<line x1="12" y1="10" x2="12" y2="24"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="app-name">SDR</div>
|
||||
</a>
|
||||
|
||||
<a href=":2100" class="app-item" data-name="Speed Test">
|
||||
<div class="app-icon-wrapper">
|
||||
<svg class="app-icon" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="app-name">Situation Room</div>
|
||||
</a>
|
||||
|
||||
<a href=":2100" class="app-item" data-name="Speed Test">
|
||||
<div class="app-icon-wrapper">
|
||||
<svg class="app-icon" viewBox="0 0 24 24">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="app-name">Speed Test</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+368
@@ -0,0 +1,368 @@
|
||||
|
||||
class Dashboard {
|
||||
constructor() {
|
||||
this.statusCache = null;
|
||||
this.lastFetch = 0;
|
||||
this.hostname = 'localhost';
|
||||
this.serverTimeOffset = null; // Track difference between server and client time
|
||||
this.isTimeServerSynced = false; // Track if we're using server time
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.fetchHostname();
|
||||
this.updateHostname();
|
||||
this.startTimeUpdates(); // Start with client time immediately
|
||||
this.startStatusUpdates();
|
||||
this.initializeEventListeners();
|
||||
}
|
||||
|
||||
async fetchHostname() {
|
||||
try {
|
||||
const response = await fetch('/api/hostname');
|
||||
const data = await response.json();
|
||||
this.hostname = data.hostname;
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch hostname:', error);
|
||||
}
|
||||
}
|
||||
|
||||
updateHostname() {
|
||||
const hostnameElement = document.querySelector('.hostname');
|
||||
if (hostnameElement) {
|
||||
hostnameElement.textContent = this.hostname;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchStatus() {
|
||||
try {
|
||||
// Record start time for latency calculation
|
||||
const requestStart = performance.now();
|
||||
const response = await fetch('/api/status');
|
||||
const requestEnd = performance.now();
|
||||
|
||||
if (!response.ok) throw new Error('Status fetch failed');
|
||||
|
||||
const status = await response.json();
|
||||
this.statusCache = status;
|
||||
this.updateUI(status, requestStart, requestEnd);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch status:', error);
|
||||
this.showOfflineStatus();
|
||||
}
|
||||
}
|
||||
|
||||
updateUI(status, requestStart, requestEnd) {
|
||||
this.updateSystemMetrics(status.system);
|
||||
this.updateGPSStatus(status.gps);
|
||||
this.updateNetworkStatus(status.network);
|
||||
this.updateLoRaStatus(status.lora);
|
||||
this.updateTimeSync(status.time, requestStart, requestEnd);
|
||||
}
|
||||
|
||||
updateSystemMetrics(system) {
|
||||
// Update temperature
|
||||
const tempElement = document.getElementById('temp');
|
||||
if (tempElement && system.temperature) {
|
||||
tempElement.textContent = `${system.temperature}°C`;
|
||||
}
|
||||
|
||||
// Update load average
|
||||
const loadElement = document.getElementById('load');
|
||||
if (loadElement && system.load !== undefined) {
|
||||
loadElement.textContent = `${system.load.toFixed(1)} Load`;
|
||||
}
|
||||
|
||||
// Update progress bars
|
||||
this.updateProgressBar('cpu-usage', system.cpu);
|
||||
this.updateProgressBar('memory-usage', system.memory);
|
||||
this.updateProgressBar('disk-usage', system.storage);
|
||||
}
|
||||
|
||||
updateProgressBar(id, percentage) {
|
||||
const fillElement = document.getElementById(`${id}-fill`);
|
||||
const textElement = document.getElementById(`${id}-text`);
|
||||
|
||||
if (fillElement && textElement) {
|
||||
const value = Math.round(percentage);
|
||||
fillElement.style.width = `${value}%`;
|
||||
textElement.textContent = `${value}%`;
|
||||
|
||||
// Update color based on usage
|
||||
if (value > 80) {
|
||||
fillElement.style.background = '#ef4444'; // Red
|
||||
} else if (value > 60) {
|
||||
fillElement.style.background = '#f59e0b'; // Orange
|
||||
} else {
|
||||
fillElement.style.background = '#10b981'; // Green
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateGPSStatus(gps) {
|
||||
const gpsStatus = document.getElementById('gps-status');
|
||||
const gpsTile = document.getElementById('gps-tile');
|
||||
|
||||
if (gpsStatus && gpsTile) {
|
||||
const hasValidFix = gps.fix !== 'No Fix' && gps.latitude !== null;
|
||||
|
||||
// Update status indicator
|
||||
if (hasValidFix) {
|
||||
gpsStatus.classList.remove('disconnected');
|
||||
gpsStatus.classList.add('connected');
|
||||
gpsTile.classList.remove('disconnected');
|
||||
gpsTile.classList.add('connected');
|
||||
} else {
|
||||
gpsStatus.classList.remove('connected');
|
||||
gpsStatus.classList.add('disconnected');
|
||||
gpsTile.classList.remove('connected');
|
||||
gpsTile.classList.add('disconnected');
|
||||
}
|
||||
|
||||
// Update GPS tile info
|
||||
const gpsInfo = gpsTile.querySelector('.tile-info');
|
||||
const gpsDetail = gpsTile.querySelector('.tile-detail');
|
||||
|
||||
if (gpsInfo && gpsDetail) {
|
||||
if (hasValidFix) {
|
||||
gpsInfo.textContent = `${gps.latitude.toFixed(4)}, ${gps.longitude.toFixed(4)}`;
|
||||
gpsDetail.textContent = `${gps.fix} • ${gps.satellites} sats • Alt: ${Math.round(gps.altitude)}m`;
|
||||
} else {
|
||||
gpsInfo.textContent = gps.satellites > 0 ? 'Acquiring Fix...' : 'No Signal';
|
||||
gpsDetail.textContent = `${gps.satellites} satellites`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateNetworkStatus(network) {
|
||||
// Update WiFi status
|
||||
const wifiTile = document.getElementById('wifi-tile');
|
||||
if (wifiTile) {
|
||||
const wifiInfo = wifiTile.querySelector('.tile-info');
|
||||
const wifiDetail = wifiTile.querySelector('.tile-detail');
|
||||
|
||||
if (wifiInfo && wifiDetail) {
|
||||
if (network.wifi.mode === 'ap') {
|
||||
wifiInfo.textContent = 'Access Point';
|
||||
wifiDetail.textContent = `SSID: ${network.wifi.ssid}`;
|
||||
} else {
|
||||
wifiInfo.textContent = 'Client Mode';
|
||||
wifiDetail.textContent = `SSID: ${network.wifi.ssid}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update Ethernet status
|
||||
const ethernetStatus = document.getElementById('ethernet-status');
|
||||
const ethernetTile = document.getElementById('ethernet-tile');
|
||||
|
||||
if (ethernetStatus && ethernetTile) {
|
||||
if (network.ethernet.connected) {
|
||||
ethernetStatus.classList.remove('disconnected');
|
||||
ethernetStatus.classList.add('connected');
|
||||
ethernetTile.classList.remove('disconnected');
|
||||
ethernetTile.classList.add('connected');
|
||||
|
||||
const ethernetInfo = ethernetTile.querySelector('.tile-info');
|
||||
const ethernetDetail = ethernetTile.querySelector('.tile-detail');
|
||||
if (ethernetInfo && ethernetDetail) {
|
||||
ethernetInfo.textContent = 'Connected';
|
||||
ethernetDetail.textContent = 'Link detected';
|
||||
}
|
||||
} else {
|
||||
ethernetStatus.classList.remove('connected');
|
||||
ethernetStatus.classList.add('disconnected');
|
||||
ethernetTile.classList.remove('connected');
|
||||
ethernetTile.classList.add('disconnected');
|
||||
|
||||
const ethernetInfo = ethernetTile.querySelector('.tile-info');
|
||||
const ethernetDetail = ethernetTile.querySelector('.tile-detail');
|
||||
if (ethernetInfo && ethernetDetail) {
|
||||
ethernetInfo.textContent = 'Disconnected';
|
||||
ethernetDetail.textContent = 'No cable detected';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateLoRaStatus(lora) {
|
||||
const loraStatus = document.getElementById('lora-status');
|
||||
const loraTile = document.getElementById('lora-tile');
|
||||
|
||||
if (loraStatus && loraTile) {
|
||||
if (lora.connected) {
|
||||
loraStatus.classList.remove('disconnected');
|
||||
loraStatus.classList.add('connected');
|
||||
loraTile.classList.remove('disconnected');
|
||||
loraTile.classList.add('connected');
|
||||
|
||||
const loraInfo = loraTile.querySelector('.tile-info');
|
||||
const loraDetail = loraTile.querySelector('.tile-detail');
|
||||
|
||||
if (loraInfo && loraDetail) {
|
||||
loraInfo.textContent = `${lora.onlineNodes}/${lora.totalNodes} Online`;
|
||||
loraDetail.textContent = `CH: ${lora.channelUtilization.toFixed(1)}% • Air: ${lora.airtimeUtilization.toFixed(1)}%`;
|
||||
}
|
||||
} else {
|
||||
loraStatus.classList.remove('connected');
|
||||
loraStatus.classList.add('disconnected');
|
||||
loraTile.classList.remove('connected');
|
||||
loraTile.classList.add('disconnected');
|
||||
|
||||
const loraInfo = loraTile.querySelector('.tile-info');
|
||||
const loraDetail = loraTile.querySelector('.tile-detail');
|
||||
|
||||
if (loraInfo && loraDetail) {
|
||||
loraInfo.textContent = 'Disconnected';
|
||||
loraDetail.textContent = 'No device found';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateTimeSync(serverTimeString, requestStart, requestEnd) {
|
||||
if (serverTimeString && requestStart && requestEnd) {
|
||||
// Calculate network latency (round-trip time / 2)
|
||||
const networkLatency = (requestEnd - requestStart) / 2;
|
||||
|
||||
// Parse server time and adjust for network latency
|
||||
const serverTime = new Date(serverTimeString);
|
||||
const adjustedServerTime = new Date(serverTime.getTime() + networkLatency);
|
||||
|
||||
// Calculate the offset between adjusted server time and client time
|
||||
const clientTime = new Date();
|
||||
this.serverTimeOffset = adjustedServerTime.getTime() - clientTime.getTime();
|
||||
this.isTimeServerSynced = true;
|
||||
|
||||
console.log(`Time synchronized with server (latency: ${networkLatency.toFixed(1)}ms, offset: ${this.serverTimeOffset.toFixed(1)}ms)`);
|
||||
}
|
||||
|
||||
// Update time displays
|
||||
this.updateCurrentTime();
|
||||
}
|
||||
|
||||
updateCurrentTime() {
|
||||
const now = new Date();
|
||||
let displayTime;
|
||||
|
||||
if (this.isTimeServerSynced && this.serverTimeOffset !== null) {
|
||||
// Use server-synchronized time
|
||||
displayTime = new Date(now.getTime() + this.serverTimeOffset);
|
||||
} else {
|
||||
// Use client time (default)
|
||||
displayTime = now;
|
||||
}
|
||||
|
||||
this.updateTimeDisplay('local', displayTime, false);
|
||||
this.updateTimeDisplay('zulu', displayTime, true);
|
||||
}
|
||||
|
||||
updateTimeDisplay(type, date, isUtc) {
|
||||
const timeElement = document.getElementById(`${type}-time`);
|
||||
const dateElement = document.getElementById(`${type}-date`);
|
||||
|
||||
if (timeElement && dateElement) {
|
||||
const displayDate = isUtc ? new Date(date.getTime()) : date;
|
||||
|
||||
const timeStr = isUtc ?
|
||||
displayDate.toUTCString().split(' ')[4] :
|
||||
displayDate.toLocaleTimeString();
|
||||
|
||||
const dateStr = isUtc ?
|
||||
displayDate.toUTCString().split(' ').slice(0, 4).join(' ') :
|
||||
displayDate.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
|
||||
timeElement.textContent = timeStr;
|
||||
dateElement.textContent = dateStr;
|
||||
|
||||
// Color time orange when using client time, white when server-synced
|
||||
if (this.isTimeServerSynced) {
|
||||
timeElement.style.color = '#fff';
|
||||
dateElement.style.color = '#64748b';
|
||||
} else {
|
||||
timeElement.style.color = '#f59e0b';
|
||||
dateElement.style.color = '#f59e0b';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showOfflineStatus() {
|
||||
// Reset to client time and show orange color
|
||||
this.serverTimeOffset = null;
|
||||
this.isTimeServerSynced = false;
|
||||
console.warn('System appears offline - falling back to client time');
|
||||
}
|
||||
|
||||
startStatusUpdates() {
|
||||
// Initial fetch
|
||||
this.fetchStatus();
|
||||
|
||||
// Update every 60 seconds
|
||||
setInterval(() => {
|
||||
this.fetchStatus();
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
startTimeUpdates() {
|
||||
// Start immediately with client time
|
||||
this.updateCurrentTime();
|
||||
|
||||
// Update time every second
|
||||
setInterval(() => {
|
||||
this.updateCurrentTime();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
initializeEventListeners() {
|
||||
// Expand/collapse status
|
||||
const expandToggle = document.getElementById('expand-toggle');
|
||||
const expandedStatus = document.getElementById('expanded-status');
|
||||
const expandIcon = document.getElementById('expand-icon');
|
||||
|
||||
if (expandToggle && expandedStatus && expandIcon) {
|
||||
expandToggle.addEventListener('click', () => {
|
||||
expandedStatus.classList.toggle('visible');
|
||||
expandIcon.classList.toggle('expanded');
|
||||
});
|
||||
}
|
||||
|
||||
// Search functionality
|
||||
const searchInput = document.getElementById('search-input');
|
||||
const appsGrid = document.getElementById('apps-grid');
|
||||
|
||||
if (searchInput && appsGrid) {
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
const query = e.target.value.toLowerCase();
|
||||
const apps = appsGrid.querySelectorAll('.app-item');
|
||||
|
||||
apps.forEach(app => {
|
||||
const name = app.getAttribute('data-name')?.toLowerCase() || '';
|
||||
app.style.display = name.includes(query) ? 'flex' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openKiwixm(event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
window.location.href = window.location.origin.replace(/:\d+/g, '') + ':1300/admin';
|
||||
}
|
||||
|
||||
// Initialize dashboard when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new Dashboard();
|
||||
|
||||
document.querySelectorAll('.app-item[href^=":"]').forEach(link => {
|
||||
const port = link.getAttribute('href').substring(1); // Remove the ':'
|
||||
const url = new URL(location.origin);
|
||||
url.port = port;
|
||||
link.href = url.toString();
|
||||
});
|
||||
});
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "MRS",
|
||||
"short_name": "MRS",
|
||||
"description": "Multipurpose Radio Server",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#000000",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"lang": "en-US",
|
||||
"scope": "/"
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Check permissions
|
||||
if [ "$UID" != "0" ]; then
|
||||
echo "This script must be run as root / with sudo" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get settings
|
||||
read -rsp "User Password: " pass; echo
|
||||
RID=$(cat /etc/radio_id)
|
||||
read -rp "Radio ID (${RID:-1-254}): " ip
|
||||
ip=${ip:-$RID}
|
||||
echo $ip > /etc/radio_id
|
||||
read -rp "Radio Name ($HOSTNAME): " name
|
||||
name=${name:-$HOSTNAME}
|
||||
read -rp "Mesh Name: " mesh_ssid
|
||||
read -rsp "Mesh Password: " mesh_pass; echo
|
||||
read -rsp "Hotspot Password: " ap_pass; echo
|
||||
read -p "Enable wireguard (Y/N): " wireguard
|
||||
if [ "${wireguard,,}" == "y" ]; then
|
||||
while [ ! -f "/etc/wireguard/wg0.conf" ]; do
|
||||
read -n 1 -s -p "Add '/etc/wireguard/wg0.conf' then press any key to continue..."
|
||||
done
|
||||
fi
|
||||
|
||||
# Install updates & packages
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt update && apt upgrade -y && apt install -y \
|
||||
build-essential libpcap-dev libusb-1.0-0-dev libnetfilter-queue-dev \
|
||||
btop jq tmux unzip zip
|
||||
|
||||
# Update hostname
|
||||
hostnamectl set-hostname "${name}"
|
||||
sed -i "s/$HOSTNAME/$name" /etc/hostname
|
||||
sed -i "s/$HOSTNAME/$name" /etc/hosts
|
||||
|
||||
# Update banner
|
||||
cat <<EOF >> /etc/motd
|
||||
|
||||
██████ ██████ ███████████ █████████
|
||||
░░██████ ██████ ░░███░░░░░███ ███░░░░░███
|
||||
░███░█████░███ ░███ ░███ ░███ ░░░
|
||||
░███░░███ ░███ ░██████████ ░░█████████
|
||||
░███ ░░░ ░███ ░███░░░░░███ ░░░░░░░░███
|
||||
░███ ░███ ░███ ░███ ███ ░███
|
||||
█████ █████ █████ █████░░█████████
|
||||
░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░░░░░
|
||||
|
||||
EOF
|
||||
|
||||
# Setup wireguard
|
||||
if [ "${wireguard,,}" == "y" ]; then
|
||||
apt install wireguard -y
|
||||
systemctl enable wg-quick@wg0
|
||||
systemctl start wg-quick@wg0
|
||||
fi
|
||||
|
||||
# Setup GPS - gpsmon
|
||||
sed -i '/^enable_uart=/d' /boot/firmware/config.txt
|
||||
echo "enable_uart=1" >> /boot/firmware/config.txt
|
||||
apt install -y gpsd gpsd-clients chrony
|
||||
tee /etc/default/gpsd > /dev/null <<'EOF'
|
||||
START_DAEMON="true"
|
||||
GPSD_OPTIONS="-n"
|
||||
DEVICES="/dev/serial0"
|
||||
USBAUTO="false"
|
||||
GPSD_SOCKET="/var/run/gpsd.sock"
|
||||
EOF
|
||||
tee -a /etc/chrony/chrony.conf > /dev/null <<'EOF'
|
||||
refclock SHM 0 offset 0.5 delay 0.2 refid NMEA
|
||||
EOF
|
||||
systemctl enable --now gpsd chrony
|
||||
|
||||
# Setup DNS
|
||||
# TODO: Setup AP management
|
||||
apt install -y dnsmasq
|
||||
tee -a /etc/dnsmasq.d/local.conf > /dev/null <<'EOF'
|
||||
server=1.1.1.1
|
||||
server=1.0.0.1
|
||||
address=/$HOSTNAME/$IP
|
||||
address=/.$HOSTNAME/$IP
|
||||
EOF
|
||||
|
||||
# Install docker
|
||||
apt install -y qemu-user-static
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
usermod -aG docker $USER
|
||||
newgrp docker
|
||||
|
||||
# Setup Guacamole - http://localhost:8080
|
||||
sed -i 's/^#\?PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config
|
||||
systemctl restart ssh
|
||||
apt-get install -y xrdp
|
||||
tee /etc/xrdp/startwm.sh > /dev/null <<'EOF'
|
||||
#!/bin/sh
|
||||
unset DBUS_SESSION_BUS_ADDRESS
|
||||
unset XDG_RUNTIME_DIR
|
||||
exec startlxde-pi
|
||||
EOF
|
||||
mkdir -p /etc/polkit-1/rules.d
|
||||
tee /etc/polkit-1/rules.d/49-nopasswd.rules > /dev/null <<'EOF'
|
||||
polkit.addRule(function(action, subject) {
|
||||
return polkit.Result.YES;
|
||||
});
|
||||
EOF
|
||||
systemctl restart polkit
|
||||
systemctl enable --now xrdp
|
||||
docker run -d --name guacamole --network host --restart unless-stopped -v guacamole:/config flcontainers/guacamole:latest
|
||||
|
||||
# Setup Automount + FileBrowser - http://localhost:1200
|
||||
mkdir -p /etc/udev/rules.d/
|
||||
echo 'ENV{ID_FS_USAGE}=="filesystem", ENV{UDISKS_FILESYSTEM_SHARED}="1"' > /etc/udev/rules.d/99-udisks2.rules
|
||||
docker run -d --name filebrowser --restart unless-stopped -p 1200:8080 -e FB_USERNAME=$USER -v filebrowser:/config -v /media:/data hurlenko/filebrowser:latest
|
||||
sleep 3
|
||||
fb_pass=$(docker logs filebrowser 2>&1 | grep -oP 'randomly generated password: \K.*'); echo $fb_pass
|
||||
|
||||
# Setup Kiwix - http://localhost:1300
|
||||
mkdir -p /media/library/archive
|
||||
mkdir -p /media/library/zim
|
||||
docker run -d --name kiwix --restart unless-stopped -p 1300:3000 -v /media/library:/data ztimson/kiwixm:latest
|
||||
|
||||
# Maps - http://localhost:1400
|
||||
mkdir -p /media/maps/tiles
|
||||
mkdir -p /media/maps/styles
|
||||
docker run -d --name maps --restart unless-stopped -p 1400:8080 -v /media/maps/config.json:/config.json -v /media/maps/tiles:/data -v /media/maps/styles:/styles maptiler/tileserver-gl:v4.11.0
|
||||
|
||||
# Setup Ollama + OpenWebUI - http://localhost:1000
|
||||
docker run -d --name ollama --restart unless-stopped -p 11434:11434 -v ollama:/root/.ollama ollama/ollama:latest
|
||||
docker exec -d ollama ollama pull llama3.2:1b-instruct-q4_K_M # Works best on Pi5 (High TPS, Low memory)
|
||||
docker run -d --name open-webui --restart unless-stopped -p 1000:8080 -e OLLAMA_BASE_URL=http://ollama:11434 --link ollama -v open-webui:/app/backend/data ghcr.io/open-webui/open-webui:main
|
||||
|
||||
# LoRa - http://localhost:4403
|
||||
pip install --upgrade --break-system-packages meshtastic
|
||||
LORA_DEVICE=$(ls /dev/serial/by-id | grep RAK)
|
||||
apt install -y ser2net
|
||||
rm /etc/ser2net.yaml || echo
|
||||
tee /etc/ser2net.conf <<EOF
|
||||
4403:raw:0:/dev/serial/by-id/$LORA_DEVICE:115200 8DATABITS NONE 1STOPBIT
|
||||
EOF
|
||||
sed -i 's|\$CONFFILE|/etc/ser2net.conf|g' /lib/systemd/system/ser2net.service
|
||||
systemctl daemon-reload
|
||||
systemctl restart ser2net
|
||||
|
||||
# Federated chat - http://localhost:1500
|
||||
docker run -it --rm -v synapse:/data -e SYNAPSE_SERVER_NAME=server.local -e SYNAPSE_REPORT_STATS=no matrixdotorg/synapse:latest generate
|
||||
tee -a /var/lib/docker/volumes/synapse/_data/homeserver.yaml <<EOF
|
||||
enable_registration: true
|
||||
enable_registration_without_verification: true
|
||||
EOF
|
||||
docker run -d --name synapse --restart unless-stopped -e SYNAPSE_SERVER_NAME=server.local -e SYNAPSE_REPORT_STATS=no -v synapse:/data -p 8008:8008 matrixdotorg/synapse:latest
|
||||
mkdir -p /etc/element
|
||||
echo "{\"default_server_config\":{\"m.homeserver\":{\"base_url\":\"http://$IP:8008\",\"server_name\":\"server.local\"}}}" > /etc/element/config.json
|
||||
docker run -d --name element --restart unless-stopped -p 1500:80 -v /etc/element/config.json:/app/config.json vectorim/element-web:latest
|
||||
|
||||
# Setup pentest suite - http:localhost:1600
|
||||
sudo apt install -y arp-scan aircrack-ng gobuster hashcat hcxtools hydra john netcat-openbsd nikto nmap masscan reaver sqlmap tcpdump wireshark
|
||||
curl https://raw.githubusercontent.com/rapid7/metasploit-omnibus/master/config/templates/metasploit-framework-wrappers/msfupdate.erb > msfinstall
|
||||
chmod +x msfinstall
|
||||
./msfinstall
|
||||
rm msfinstall
|
||||
docker run -d --name bettercap --restart unless-stopped --privileged --net=host bettercap/bettercap -eval "set api.rest.address 0.0.0.0; set ui.address 0.0.0.0; set ui.port 1600; ui on; set gps.device localhost:2947;"
|
||||
|
||||
# ATAK - http://localhost:1100 (XMPP - 5222, API - 8089)
|
||||
docker run -d --name atak --restart unless-stopped --platform=linux/amd64 -p 8089:8080 -p 5222:8999 -p 1100:8088 -v atak:/app kdudkov/goatak_server:latest
|
||||
|
||||
# TODO: SDR tools (SDRangel, dump1060)
|
||||
#docker run -d --name sdrserver --restart unless-stopped --device /dev/bus/usb:/dev/bus/usb -p 8091:8091 -p 8092:8092 f4exb06/sdrangelsrv:v7.22.7 --bind 0.0.0.0
|
||||
#docker run -d --name sdrcli --restart unless-stopped -p 1000:8080 -e SDRANGEL_SERVER_URL=http://10.69.5.108:8091 f4exb06/sdrangelcli:v4.0.2
|
||||
|
||||
# Speedtest - http://localhost:1900
|
||||
docker run -d --name speedtest --restart unless-stopped -p 1900:80 lscr.io/linuxserver/librespeed:latest
|
||||
|
||||
# TODO: Captive Portal + PWA & Caddy
|
||||
# TODO: E-Ink + QR Join AP
|
||||
# TODO: Mesh
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
|
||||
import express from "express";
|
||||
import { join } from 'path';
|
||||
import {environment} from './services/environment.mjs';
|
||||
import { statusRouter } from './services/status.mjs';
|
||||
|
||||
(async () => {
|
||||
const app = express();
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Middleware error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
// Routes
|
||||
console.log(join(environment.root, '../public'));
|
||||
app.use(express.static(join(environment.root, '../public')));
|
||||
|
||||
app.use('/api', statusRouter);
|
||||
|
||||
// Error handler
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Unhandled error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
// Startup
|
||||
const server = app.listen(environment.port, () => {
|
||||
console.log(`Dashboard running on http://localhost:${environment.port}`);
|
||||
}).on('error', (err) => {
|
||||
console.error('Server startup error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Shutdown
|
||||
const gracefulShutdown = (signal) => {
|
||||
console.log(`Received ${signal}, shutting down gracefully`);
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
console.error('Error during server shutdown:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
})();
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
import dotenv from 'dotenv';
|
||||
import * as fs from 'node:fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
import { execSync } from "child_process";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export const environment = {
|
||||
config: process.env.CONFIG || 'config.json',
|
||||
hostname: process.env.HOSTNAME || execSync('hostname', { encoding: "utf-8" }) || 'localhost',
|
||||
gpsd: process.env.GPSD || 2947,
|
||||
port: process.env.PORT || 3000,
|
||||
root: dirname(dirname(fileURLToPath(import.meta.url))),
|
||||
settings: {},
|
||||
}
|
||||
|
||||
if(!environment.config.startsWith('/')) environment.config = `${environment.root}/${environment.config}`;
|
||||
|
||||
export function loadSettings() {
|
||||
if(!fs.existsSync(environment.config)) fs.writeFileSync(environment.config, '', { flag: 'a' });
|
||||
const value = fs.readFileSync(environment.config, 'utf-8');
|
||||
try { return JSON.parse(value); }
|
||||
catch (e) {
|
||||
return {
|
||||
lora: {
|
||||
mode: 'MESH',
|
||||
telemetry: true,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSettings() {
|
||||
fs.writeFileSync(environment.config, JSON.stringify(environment.settings, null, 4));
|
||||
}
|
||||
|
||||
loadSettings();
|
||||
Executable
+426
@@ -0,0 +1,426 @@
|
||||
|
||||
import express from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import pkg from 'node-gpsd';
|
||||
const { GPS } = pkg;
|
||||
import {environment} from './environment.mjs';
|
||||
|
||||
const router = express.Router();
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Status cache with TTL
|
||||
let statusCache = null;
|
||||
let lastUpdate = 0;
|
||||
const CACHE_TTL = 15 * 1000; // 15 seconds
|
||||
|
||||
// GPS client setup
|
||||
let gpsClient = null;
|
||||
let gpsData = {
|
||||
fix: 'No Fix',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
altitude: null,
|
||||
satellites: 0
|
||||
};
|
||||
|
||||
// Initialize GPS connection
|
||||
async function initGPS() {
|
||||
try {
|
||||
gpsClient = new GPS({ hostname: 'localhost', port: 2947 });
|
||||
|
||||
gpsClient.on('TPV', (data) => {
|
||||
gpsData.latitude = data.lat || null;
|
||||
gpsData.longitude = data.lon || null;
|
||||
gpsData.altitude = data.alt || null;
|
||||
gpsData.fix = data.mode === 3 ? '3D Fix' : data.mode === 2 ? '2D Fix' : 'No Fix';
|
||||
});
|
||||
|
||||
gpsClient.on('SKY', (data) => {
|
||||
gpsData.satellites = (data.satellites || []).length;
|
||||
});
|
||||
|
||||
gpsClient.on('error', (err) => {
|
||||
console.warn('GPS error:', err.message);
|
||||
});
|
||||
|
||||
await gpsClient.watch();
|
||||
console.log('GPS client initialized');
|
||||
} catch (error) {
|
||||
console.warn('GPS not available:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// CLI-based system information gathering
|
||||
async function getSystemInfo() {
|
||||
const info = {
|
||||
temperature: 0,
|
||||
load: 0,
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
storage: 0
|
||||
};
|
||||
|
||||
try {
|
||||
// CPU temperature (Raspberry Pi specific)
|
||||
if (existsSync('/sys/class/thermal/thermal_zone0/temp')) {
|
||||
const tempData = readFileSync('/sys/class/thermal/thermal_zone0/temp', 'utf8');
|
||||
info.temperature = Math.round(parseInt(tempData.trim()) / 1000);
|
||||
}
|
||||
|
||||
// Load average
|
||||
const { stdout: loadAvg } = await execAsync("cat /proc/loadavg | awk '{print $1}'");
|
||||
info.load = parseFloat(loadAvg.trim()) || 0;
|
||||
|
||||
// CPU usage
|
||||
const { stdout: cpuUsage } = await execAsync("grep 'cpu ' /proc/stat | awk '{usage=($2+$4)*100/($2+$3+$4+$5)} END {print usage}'");
|
||||
info.cpu = parseFloat(cpuUsage.trim()) || 0;
|
||||
|
||||
// Memory usage percentage
|
||||
const { stdout: memUsage } = await execAsync("free | awk 'NR==2{printf \"%.1f\", $3*100/$2}'");
|
||||
info.memory = parseFloat(memUsage.trim()) || 0;
|
||||
|
||||
// Disk usage percentage for root filesystem
|
||||
const { stdout: diskUsage } = await execAsync("df / | awk 'NR==2{printf \"%.1f\", $5}' | sed 's/%//'");
|
||||
info.storage = parseFloat(diskUsage.trim()) || 0;
|
||||
|
||||
} catch (error) {
|
||||
console.warn('System info error:', error.message);
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
async function getGPSInfo() {
|
||||
const gpsInfo = {
|
||||
satellites: 0,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
altitude: null
|
||||
};
|
||||
|
||||
try {
|
||||
// Check if gpsd is running
|
||||
await execAsync('systemctl is-active gpsd');
|
||||
|
||||
// Try to get GPS data using gpspipe
|
||||
const { stdout: gpsRaw } = await execAsync('timeout 3s gpspipe -w -n 5 | grep -E \'"class":"TPV"\' | head -1', { timeout: 4000 });
|
||||
|
||||
if (gpsRaw.trim()) {
|
||||
const gpsJson = JSON.parse(gpsRaw.trim());
|
||||
gpsInfo.latitude = gpsJson.lat || null;
|
||||
gpsInfo.longitude = gpsJson.lon || null;
|
||||
gpsInfo.altitude = gpsJson.alt || null;
|
||||
}
|
||||
|
||||
// Get satellite count
|
||||
try {
|
||||
const { stdout: skyRaw } = await execAsync('timeout 2s gpspipe -w -n 3 | grep -E \'"class":"SKY"\' | head -1', { timeout: 3000 });
|
||||
if (skyRaw.trim()) {
|
||||
const skyJson = JSON.parse(skyRaw.trim());
|
||||
gpsInfo.satellites = (skyJson.satellites || []).length;
|
||||
}
|
||||
} catch (skyError) { }
|
||||
} catch (error) {
|
||||
console.warn('GPS info error:', error.message);
|
||||
// Use cached GPS data from node-gpsd if available
|
||||
if (gpsData.latitude !== null) {
|
||||
return gpsData;
|
||||
}
|
||||
}
|
||||
|
||||
return gpsInfo;
|
||||
}
|
||||
|
||||
async function getNetworkInfo() {
|
||||
const networkInfo = {
|
||||
wifi: {
|
||||
mode: 'client',
|
||||
ssid: 'Unknown'
|
||||
},
|
||||
ethernet: {
|
||||
connected: false
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Check if hostapd is running (AP mode)
|
||||
try {
|
||||
await execAsync('systemctl is-active hostapd');
|
||||
networkInfo.wifi.mode = 'ap';
|
||||
|
||||
// Get AP SSID from hostapd config or use hostname
|
||||
try {
|
||||
const { stdout: hostname } = await execAsync('hostname');
|
||||
networkInfo.wifi.ssid = process.env.AP_SSID || hostname.trim();
|
||||
} catch {
|
||||
networkInfo.wifi.ssid = 'MRS-AP';
|
||||
}
|
||||
} catch {
|
||||
// Not in AP mode, check if connected to WiFi
|
||||
try {
|
||||
// First, try iwconfig directly since it's more reliable for ESSID
|
||||
const { stdout: iwconfig } = await execAsync("iwconfig wlan0 2>/dev/null");
|
||||
const essidMatch = iwconfig.match(/ESSID:"([^"]+)"/);
|
||||
|
||||
if (essidMatch && essidMatch[1] && essidMatch[1] !== 'off/any') {
|
||||
networkInfo.wifi.mode = 'client';
|
||||
networkInfo.wifi.ssid = essidMatch[1];
|
||||
} else {
|
||||
// Fallback to nmcli if iwconfig doesn't work
|
||||
try {
|
||||
const { stdout: activeWifi } = await execAsync("nmcli -t -f NAME,TYPE,DEVICE connection show --active | grep -E '(wifi|802-11-wireless)' | cut -d':' -f1 | head -1");
|
||||
if (activeWifi.trim()) {
|
||||
networkInfo.wifi.mode = 'client';
|
||||
networkInfo.wifi.ssid = activeWifi.trim();
|
||||
} else {
|
||||
// Try getting SSID from currently connected interface
|
||||
const { stdout: connectedSSID } = await execAsync("nmcli -t -f active,ssid dev wifi | grep '^yes' | cut -d':' -f2 | head -1");
|
||||
if (connectedSSID.trim()) {
|
||||
networkInfo.wifi.mode = 'client';
|
||||
networkInfo.wifi.ssid = connectedSSID.trim();
|
||||
} else {
|
||||
throw new Error('No active WiFi connection found');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Final fallback: check if wlan0 interface is up and get info via ip
|
||||
const { stdout: wlanStatus } = await execAsync("ip addr show wlan0 2>/dev/null | grep 'inet ' | wc -l");
|
||||
if (parseInt(wlanStatus.trim()) > 0) {
|
||||
// Interface has IP but we can't determine SSID
|
||||
networkInfo.wifi.mode = 'client';
|
||||
networkInfo.wifi.ssid = 'Connected (Unknown SSID)';
|
||||
} else {
|
||||
networkInfo.wifi.mode = 'disconnected';
|
||||
networkInfo.wifi.ssid = 'Not Connected';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('WiFi detection error:', error.message);
|
||||
networkInfo.wifi.mode = 'disconnected';
|
||||
networkInfo.wifi.ssid = 'Not Connected';
|
||||
}
|
||||
}
|
||||
|
||||
// Check Ethernet status
|
||||
try {
|
||||
const { stdout: ethStatus } = await execAsync("ip link show eth0 | grep 'state UP'");
|
||||
networkInfo.ethernet.connected = ethStatus.includes('state UP');
|
||||
} catch {
|
||||
networkInfo.ethernet.connected = false;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Network info error:', error.message);
|
||||
}
|
||||
|
||||
return networkInfo;
|
||||
}
|
||||
|
||||
let loraInfo = null;
|
||||
async function getLoRaInfo() {
|
||||
if(loraInfo) return loraInfo;
|
||||
|
||||
loraInfo = {
|
||||
connected: false,
|
||||
onlineNodes: 0,
|
||||
totalNodes: 0,
|
||||
channelUtilization: 0,
|
||||
airtimeUtilization: 0
|
||||
};
|
||||
|
||||
try {
|
||||
console.log('Getting LoRa/Meshtastic status...');
|
||||
|
||||
// Get node information first
|
||||
const { stdout: nodeList } = await execAsync('timeout 10s meshtastic --nodes 2>/dev/null', { timeout: 15000 });
|
||||
|
||||
if (nodeList && nodeList.trim()) {
|
||||
loraInfo.connected = true;
|
||||
|
||||
// Parse node list - look for actual node entries
|
||||
const lines = nodeList.split('\n');
|
||||
let totalNodes = 0;
|
||||
let onlineNodes = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// Skip header lines and separators
|
||||
if (trimmedLine.includes('│') &&
|
||||
!trimmedLine.includes('User') &&
|
||||
!trimmedLine.includes('───') &&
|
||||
!trimmedLine.includes('Node') &&
|
||||
trimmedLine.length > 10) {
|
||||
|
||||
totalNodes++;
|
||||
|
||||
// Check if node is online (has recent lastHeard or SNR data)
|
||||
if (trimmedLine.includes('SNR') ||
|
||||
trimmedLine.match(/\d+[smh]/) || // Time indicators like 5m, 2h, 30s
|
||||
trimmedLine.includes('now')) {
|
||||
onlineNodes++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loraInfo.totalNodes = totalNodes;
|
||||
loraInfo.onlineNodes = onlineNodes;
|
||||
}
|
||||
|
||||
// Get channel and airtime utilization from --info
|
||||
try {
|
||||
const { stdout: meshInfo } = await execAsync('timeout 10s meshtastic --info 2>/dev/null', { timeout: 15000 });
|
||||
|
||||
if (meshInfo && meshInfo.trim()) {
|
||||
const lines = meshInfo.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// Look for channel utilization (various formats)
|
||||
if (trimmedLine.includes('channelUtilization') || trimmedLine.includes('Channel utilization')) {
|
||||
const channelMatch = trimmedLine.match(/([\d.]+)%?/) || trimmedLine.match(/:\s*([\d.]+)/);
|
||||
if (channelMatch) {
|
||||
loraInfo.channelUtilization = parseFloat(channelMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Look for airtime utilization (various formats)
|
||||
if (trimmedLine.includes('airUtilTx') || trimmedLine.includes('Air time') || trimmedLine.includes('Airtime')) {
|
||||
const airtimeMatch = trimmedLine.match(/([\d.]+)%?/) || trimmedLine.match(/:\s*([\d.]+)/);
|
||||
if (airtimeMatch) {
|
||||
loraInfo.airtimeUtilization = parseFloat(airtimeMatch[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (infoError) {
|
||||
console.warn('Failed to get mesh info:', infoError.message);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn('LoRa info error:', error.message);
|
||||
loraInfo.connected = false;
|
||||
}
|
||||
|
||||
setTimeout(() => loraInfo = null, 60000)
|
||||
return loraInfo;
|
||||
}
|
||||
|
||||
// Main status generation function - matches frontend expectations
|
||||
async function generateStatus() {
|
||||
try {
|
||||
console.log('Generating fresh status...');
|
||||
|
||||
const [systemInfo, gpsInfo, networkInfo, loraInfo] = await Promise.all([
|
||||
getSystemInfo(),
|
||||
getGPSInfo(),
|
||||
getNetworkInfo(),
|
||||
getLoRaInfo()
|
||||
]);
|
||||
|
||||
// Format exactly as the frontend expects
|
||||
const status = {
|
||||
system: {
|
||||
temperature: systemInfo.temperature,
|
||||
load: systemInfo.load,
|
||||
cpu: systemInfo.cpu,
|
||||
memory: systemInfo.memory,
|
||||
storage: systemInfo.storage
|
||||
},
|
||||
gps: {
|
||||
fix: gpsInfo.fix,
|
||||
satellites: gpsInfo.satellites,
|
||||
latitude: gpsInfo.latitude,
|
||||
longitude: gpsInfo.longitude,
|
||||
altitude: gpsInfo.altitude
|
||||
},
|
||||
network: {
|
||||
wifi: networkInfo.wifi,
|
||||
ethernet: networkInfo.ethernet
|
||||
},
|
||||
lora: loraInfo,
|
||||
time: new Date().toISOString()
|
||||
};
|
||||
|
||||
return status;
|
||||
} catch (error) {
|
||||
console.error('Status generation error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/hostname', (req, res) => res.json({ hostname: environment.hostname }));
|
||||
|
||||
// Main status endpoint that matches frontend expectations
|
||||
router.get('/status', async (req, res) => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
|
||||
// Return cached status if within TTL
|
||||
if (statusCache && (now - lastUpdate) < CACHE_TTL) {
|
||||
console.log('Returning cached status');
|
||||
return res.json(statusCache);
|
||||
}
|
||||
|
||||
// Generate fresh status
|
||||
statusCache = await generateStatus();
|
||||
lastUpdate = now;
|
||||
|
||||
console.log('Status updated successfully');
|
||||
res.json(statusCache);
|
||||
} catch (error) {
|
||||
console.error('Status API error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to get system status',
|
||||
message: error.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Individual debug endpoints
|
||||
router.get('/system', async (req, res) => {
|
||||
try {
|
||||
const systemInfo = await getSystemInfo();
|
||||
res.json(systemInfo);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/gps', async (req, res) => {
|
||||
try {
|
||||
const gpsInfo = await getGPSInfo();
|
||||
res.json(gpsInfo);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/network', async (req, res) => {
|
||||
try {
|
||||
const networkInfo = await getNetworkInfo();
|
||||
res.json(networkInfo);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Add individual LoRa endpoint
|
||||
router.get('/lora', async (req, res) => {
|
||||
try {
|
||||
const loraInfo = await getLoRaInfo();
|
||||
res.json(loraInfo);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize GPS on startup
|
||||
initGPS().catch(err => console.warn('GPS initialization failed:', err.message));
|
||||
|
||||
export { router as statusRouter };
|
||||
Reference in New Issue
Block a user