init
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.idea
|
||||||
|
node_modules
|
||||||
21
LICENSE
Executable file
21
LICENSE
Executable file
@@ -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.
|
||||||
83
README.md
Executable file
83
README.md
Executable file
@@ -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
code/package-lock.json
generated
Executable file
370
code/package-lock.json
generated
Executable file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
code/package.json
Executable file
19
code/package.json
Executable file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
code/public/downloads/ATAK_5.4.0.16.apk
Executable file
BIN
code/public/downloads/ATAK_5.4.0.16.apk
Executable file
Binary file not shown.
BIN
code/public/downloads/Element.apk
Executable file
BIN
code/public/downloads/Element.apk
Executable file
Binary file not shown.
BIN
code/public/downloads/Meshtastic_2.7.1.apk
Executable file
BIN
code/public/downloads/Meshtastic_2.7.1.apk
Executable file
Binary file not shown.
BIN
code/public/downloads/Remote Desktop 8_8.1.82.445.apk
Executable file
BIN
code/public/downloads/Remote Desktop 8_8.1.82.445.apk
Executable file
Binary file not shown.
BIN
code/public/downloads/SDRangel_7.21.3.1.apk
Executable file
BIN
code/public/downloads/SDRangel_7.21.3.1.apk
Executable file
Binary file not shown.
BIN
code/public/favicon.png
Executable file
BIN
code/public/favicon.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
953
code/public/index.html
Executable file
953
code/public/index.html
Executable file
@@ -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>
|
||||||
368
code/public/index.js
Executable file
368
code/public/index.js
Executable file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
19
code/public/manifest.json
Executable file
19
code/public/manifest.json
Executable file
@@ -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
code/src/config.json
Normal file
0
code/src/config.json
Normal file
51
code/src/main.mjs
Executable file
51
code/src/main.mjs
Executable file
@@ -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'));
|
||||||
|
})();
|
||||||
38
code/src/services/environment.mjs
Executable file
38
code/src/services/environment.mjs
Executable file
@@ -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();
|
||||||
426
code/src/services/status.mjs
Executable file
426
code/src/services/status.mjs
Executable file
@@ -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 };
|
||||||
0
docs/library.md
Executable file
0
docs/library.md
Executable file
114
docs/maps.md
Executable file
114
docs/maps.md
Executable file
@@ -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 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
76
docs/setup.md
Executable file
76
docs/setup.md
Executable file
@@ -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
|
||||||
|
```
|
||||||
17
maps/config.json
Executable file
17
maps/config.json
Executable file
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
381
maps/dark.json
Executable file
381
maps/dark.json
Executable file
@@ -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"
|
||||||
|
}
|
||||||
368
maps/light.json
Executable file
368
maps/light.json
Executable file
@@ -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"
|
||||||
|
}
|
||||||
BIN
public/favicon.png
Executable file
BIN
public/favicon.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
939
public/index.html
Executable file
939
public/index.html
Executable file
@@ -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>
|
||||||
368
public/index.js
Executable file
368
public/index.js
Executable file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
19
public/manifest.json
Executable file
19
public/manifest.json
Executable file
@@ -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": "/"
|
||||||
|
}
|
||||||
178
setup.sh
Executable file
178
setup.sh
Executable file
@@ -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
|
||||||
51
src/main.mjs
Executable file
51
src/main.mjs
Executable file
@@ -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'));
|
||||||
|
})();
|
||||||
38
src/services/environment.mjs
Executable file
38
src/services/environment.mjs
Executable file
@@ -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();
|
||||||
426
src/services/status.mjs
Executable file
426
src/services/status.mjs
Executable file
@@ -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