Initial Commit Migrating NWA from SDL
This commit is contained in:
commit
709d450f0d
|
|
@ -0,0 +1 @@
|
|||
docs/.obsidian
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 John Haverlack
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
# Node Web App (NWA)
|
||||
|
||||
| Attribute | Value |
|
||||
| --- | --- |
|
||||
| **Author** | John Haverlack |
|
||||
| **Copyright** | 2026 John Haverlack |
|
||||
| **License** | MIT |
|
||||
| **Version** | 0.1.0 |
|
||||
| **Date** | 2026-06-08 |
|
||||
|
||||
## Overview
|
||||
|
||||
The Node Web App (NWA) project provides a Node.JS based Web Application Template for building Websites with serverside API and Web Front End.
|
||||
|
||||
## Design
|
||||
|
||||

|
||||

|
||||
|
||||
For more information, see the [Design](docs/DESIGN.md) document.
|
||||
|
||||
## Getting Started
|
||||
|
||||
See also: [Installation](docs/INSTALL.md)
|
||||
|
||||
```
|
||||
$ git clone https://github.com/jehaverlack/sdl.git
|
||||
$ cd sdl
|
||||
$ ./install-sdl.sh
|
||||
```
|
||||
|
||||
Point your browser to http://IP-ADDR:8081
|
||||
|
||||
#### Firewall Rules
|
||||
|
||||
> NOTE: Firewall rules are required to allow SDL Workers to connect to your SDL Manager.
|
||||
|
||||
```
|
||||
sudo ufw allow in proto tcp from <NETWORK CIDR> to any port 8081
|
||||
sudo ufw allow in proto tcp from <NETWORK CIDR> to any port 1883
|
||||
sudo ufw allow in proto tcp from <NETWORK CIDR> to any port 2883
|
||||
sudo ufw allow in proto udp from <NETWORK CIDR> to any port 10101
|
||||
```
|
||||
|
||||
## SDL Worker Installation
|
||||
|
||||
#### Discovery of the SDL Manager
|
||||
|
||||
Listen on UDP port 10101 for beacon messages broadcast by your SDL Manager. ```nc`'` is only used for discovery, once and SDL worker is running these sets.
|
||||
|
||||
```
|
||||
nc -u -l -k 10101 | jq
|
||||
```
|
||||
|
||||
Or to get the install command from the beacon message:
|
||||
|
||||
```
|
||||
nc -u -l -k 10101 | jq -r '.msg.sdl_wkr_install_cmd[]'
|
||||
```
|
||||
|
||||
**NOTE**: If you use UFW or a local firewall, you must allow SDL Worker to listen on UDP port 10101.
|
||||
|
||||
```
|
||||
sudo ufw allow in proto udp from <NETWORK CIDR> to any port 10101
|
||||
```
|
||||
|
||||
> **NOTE**: CTRL-C to exit `nc`. You cannot run `nc` on port 10101 in parallel with the sdl-wkr. So run it once to get the install command. Then install sdl-wkr.
|
||||
|
||||
#### Installing a SDL Working Node
|
||||
|
||||
Run the **curl** or **wget** command provided by SDL Manager UDP beacon to install SDL Worker locally.
|
||||
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
{
|
||||
"releases": [
|
||||
{
|
||||
"version": "0.3.10",
|
||||
"date": "2026-02-08",
|
||||
"maturity": "ALPHA",
|
||||
"summary": "",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"version": "0.3.9",
|
||||
"date": "2026-02-08",
|
||||
"maturity": "ALPHA",
|
||||
"summary": "",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"version": "0.3.8",
|
||||
"date": "2026-02-01",
|
||||
"maturity": "ALPHA",
|
||||
"summary": "",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"version": "0.3.7",
|
||||
"date": "2026-02-01",
|
||||
"maturity": "ALPHA",
|
||||
"summary": "sdl-wkr Telemetry",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"version": "0.3.6",
|
||||
"date": "2026-02-01",
|
||||
"maturity": "ALPHA",
|
||||
"summary": "Light/Dark Mode, Wkr Install, Cluster Stats",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"version": "0.3.5",
|
||||
"date": "2026-02-01",
|
||||
"maturity": "ALPHA",
|
||||
"summary": "",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"version": "0.3.4",
|
||||
"date": "2026-01-31",
|
||||
"maturity": "ALPHA",
|
||||
"summary": "",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"version": "0.3.3",
|
||||
"date": "2026-01-31",
|
||||
"maturity": "ALPHA",
|
||||
"summary": "",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"version": "0.3.2",
|
||||
"date": "2026-01-31",
|
||||
"maturity": "ALPHA",
|
||||
"summary": "",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"version": "0.3.1",
|
||||
"date": "2026-01-31",
|
||||
"maturity": "ALPHA",
|
||||
"summary": "README Cleanup",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"version": "0.3.0",
|
||||
"date": "2026-01-31",
|
||||
"maturity": "ALPHA",
|
||||
"summary": "Initial Codebase",
|
||||
"notes": "Initial GitHub Release"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"DIRS": {
|
||||
"HOME": "~/.nwa",
|
||||
"CONF": "HOME/conf",
|
||||
"DOCS": "HOME/docs",
|
||||
"LOGS": "HOME/logs",
|
||||
"DATA": "HOME/data",
|
||||
"SCRIPTS": "HOME/scripts",
|
||||
"NODEJS": "HOME/nodejs"
|
||||
},
|
||||
"NODEJS": {
|
||||
"VERSION": "24.18.0",
|
||||
"TARGETS": [
|
||||
"linux:x64",
|
||||
"linux:arm64",
|
||||
"linux:armv7l",
|
||||
"darwin:x64",
|
||||
"darwin:arm64",
|
||||
"win:x64"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
# Software Defined Laboratory (SDL)
|
||||
|
||||
| Attribute | Value |
|
||||
| --- | --- |
|
||||
| **Author** | John Haverlack |
|
||||
| **Copyright** | 2026 John Haverlack |
|
||||
| **License** | MIT |
|
||||
| **Version** | 0.3.10 |
|
||||
| **Date** | 2026-02-08 |
|
||||
|
||||
## v0.3.10 - 2026-02-08 (ALPHA)
|
||||
|
||||
- Dash Staging Jobs
|
||||
- Dash Full sdl_id
|
||||
- Updating main Web UI Page
|
||||
- Start v0.3.10
|
||||
|
||||
## v0.3.9 - 2026-02-08 (ALPHA)
|
||||
|
||||
- v0.3.9
|
||||
- Refactor Cluster Status Dash
|
||||
- Refactor Cluster Status Dash
|
||||
- System Columnn Display
|
||||
- Start v0.3.9
|
||||
|
||||
## v0.3.8 - 2026-02-01 (ALPHA)
|
||||
|
||||
- v0.3.8
|
||||
- Refactoing Worker Dashboard
|
||||
- Refactoing Worker Dashboard
|
||||
- Refactored Cluster Status
|
||||
- Refactor of telemetry format + hw detection
|
||||
- Switched Panel Icon to Bootstrap Icon
|
||||
- Updating Worker Telemetry
|
||||
- CP
|
||||
- Fixing Wget/Curl Command Installer Port
|
||||
- Fixing Wget/Curl Command Installer Port
|
||||
- Fixing Wkr Start / Stop
|
||||
- Update stop-sdl-wkr.sh
|
||||
- WIP Install / Start / Stop
|
||||
- WIP Install / Start /Stop
|
||||
- Systemd Detectiion in Start/Stop/Status
|
||||
- WIP: Start / Stop Scripts
|
||||
- Non Systemd start stop scripts
|
||||
- WIP: start stop scripts
|
||||
- Updating Task List
|
||||
- Adding Screeshot to README
|
||||
- Start v0.3.8
|
||||
|
||||
## v0.3.7 - 2026-02-01 (ALPHA)
|
||||
|
||||
**Summary**
|
||||
sdl-wkr Telemetry
|
||||
|
||||
- v0.3.7
|
||||
- Fixing sdl-wkr RAM detection
|
||||
- Start v0.3.7
|
||||
|
||||
## v0.3.6 - 2026-02-01 (ALPHA)
|
||||
|
||||
**Summary**
|
||||
Light/Dark Mode, Wkr Install, Cluster Stats
|
||||
|
||||
- v0.3.6
|
||||
- Adding Cluster Telemetry
|
||||
- WIP: Worker Display
|
||||
- Tweaks
|
||||
- Start v0.3.6
|
||||
- CP Updates
|
||||
- Dash: Total Cluster Stats
|
||||
- Publishing to cluster/workers
|
||||
- Light/Dark Mode Toggle
|
||||
- Added light/dark theme CSS
|
||||
|
||||
## v0.3.5 - 2026-02-01 (ALPHA)
|
||||
|
||||
- v0.3.5
|
||||
- Fixing sdl-wkr install script
|
||||
- Start v0.3.5
|
||||
|
||||
## v0.3.4 - 2026-01-31 (ALPHA)
|
||||
|
||||
- Working sdl-wkr self update
|
||||
- Working sdl-wkr self update
|
||||
- Start v0.3.4
|
||||
|
||||
## v0.3.3 - 2026-01-31 (ALPHA)
|
||||
|
||||
- WIP: sdl-wkr updates
|
||||
- WIP: sdl-wkr updates
|
||||
- Start v0.3.3
|
||||
|
||||
## v0.3.2 - 2026-01-31 (ALPHA)
|
||||
|
||||
- v0.3.2
|
||||
- v0.3.2
|
||||
- Squetching Logs
|
||||
- WORKING: join/authz
|
||||
- WIP: Join Worker
|
||||
- WIP: Join Process
|
||||
- sdl-wkr Cluster Join
|
||||
- Fixing Service Module Render Bug
|
||||
- Start v0.3.2
|
||||
|
||||
## v0.3.1 - 2026-01-31 (ALPHA)
|
||||
|
||||
**Summary**
|
||||
README Cleanup
|
||||
|
||||
- v0.3.1
|
||||
- Start v0.3.1
|
||||
- Removing duplicate LICENSE file
|
||||
- README Cleanup
|
||||
|
||||
## v0.3.0 - 2026-01-31 (ALPHA)
|
||||
|
||||
**Summary**
|
||||
Initial Codebase
|
||||
|
||||
**Notes**
|
||||
Initial GitHub Release
|
||||
|
||||
- Preparing GitHub Version
|
||||
- Start v0.3.0
|
||||
- Initial commit
|
||||
|
||||
|
|
@ -0,0 +1,314 @@
|
|||
# Software Defined Laboratory (SDL)
|
||||
|
||||
| Attribute | Value |
|
||||
| --- | --- |
|
||||
| **Author** | John Haverlack |
|
||||
| **Copyright** | 2026 John Haverlack |
|
||||
| **License** | MIT |
|
||||
| **Version** | 0.3.10 |
|
||||
| **Date** | 2026-02-08 |
|
||||
|
||||
## Overview
|
||||
|
||||
The Software Defined Laboratory (SDL) project provides a generalized distributed computing platform for managing parallel computational workflows across a cluster of heterogeneous distributed nodes. SDL is a minimalistic High Performance Compute (HPC) platform focused on extreme minimizaton of technical debt associated configuration, deployability, and maintenance of the cluster.
|
||||
|
||||
# Roadmap
|
||||
|
||||
## Prioritized Tasks
|
||||
|
||||
MVP - Minimum Viable Product Task List
|
||||
|
||||
**Core Infrastructure** ✅ (Complete)
|
||||
- [x] sdl-mgr MQTT Broker
|
||||
- [x] sdl-mgr UDP Beacon
|
||||
- [x] sdl-mgr Web UI MQTT WebSocket Pub/Sub
|
||||
- [x] sdl-wkr UDP Listener
|
||||
- [x] SDL_ID Node ID
|
||||
- [x] Version Updater Script
|
||||
- [x] Deploy sdl-mgr to $SDL_HOME/sdl-mgr
|
||||
- [x] Create SDL Worker Dist Build Process
|
||||
- [x] Create SDL Worker Install to $SDL_HOME
|
||||
- [x] SDL Install Script
|
||||
- [x] Create SDL Worker Auto Update Process
|
||||
- [x] Create Worker Join Process
|
||||
- [x] Create Worker Telemetry Process
|
||||
|
||||
**Worker Monitoring & Telemetry** 🔄 (In Progress)
|
||||
- [x] Worker Hardware Inventory Detection
|
||||
- [x] Physical vs Logical CPU cores (hyperthreading)
|
||||
- [x] Physical vs VM detection (hypervisor flag)
|
||||
- [x] GPU VRAM capacity aggregation
|
||||
- [x] Memory capacity (bytes → GB conversion)
|
||||
- [x] Worker Real-time Load Status (pure Node.js)
|
||||
- [x] CPU usage percentage (`os.cpus()` times)
|
||||
- [x] RAM usage (total/free/used via `os.totalmem()/freemem()`)
|
||||
- [x] GPU usage (exec-based: nvidia-smi, rocm-smi)
|
||||
- [ ] Disk IO (optional: exec-based)
|
||||
- [x] Worker Uptime Tracking
|
||||
- [x] SDL process uptime (`process.uptime()`)
|
||||
- [x] OS uptime (`os.uptime()`)
|
||||
- [ ] Worker Display Improvements
|
||||
- [x] Show OS distro + version in Platform column (e.g., "Debian 12 / x64")
|
||||
- [x] Show system type (Physical 🖥️ / VM 💠)
|
||||
- [ ] Show GPU VRAM in GPU column (e.g., "2 GPUs (48 GB)")
|
||||
- [x] Show worker uptime in dashboard
|
||||
- [x] Display last seen as elapsed time in dashboard, not date
|
||||
- [ ] Track Username for sdl process
|
||||
- [ ] Web UI
|
||||
- [ ] Progresive Web App
|
||||
- [ ] Editable Dashboard
|
||||
- [x] Panel Icon
|
||||
- [ ] Capacity Detection
|
||||
- [ ] GPU VRAM capacity
|
||||
- [ ] CPU capacity
|
||||
- [ ] RAM capacity
|
||||
- [ ] Network Negotiontion speed
|
||||
- [ ] S3 (MinIO) storage capacity
|
||||
- [ ] SW: Python, Rust, C++, etc
|
||||
- [ ] PyTorch, Tensorflow, etc
|
||||
- [ ] Ollamma, Models, etc
|
||||
|
||||
|
||||
|
||||
**Resource Management**
|
||||
- [ ] CPU Reserve Capacity Allocation
|
||||
- [ ] Config option: `reserve_cpus: N` (keep N cores for system)
|
||||
- [ ] Track: `total`, `reserved`, `allocated`, `available`, `used`
|
||||
- [ ] Display reserved capacity in dashboard
|
||||
- [ ] Memory Reserve Capacity (optional, future)
|
||||
- [ ] GPU Reserve Capacity (optional, future)
|
||||
|
||||
**Service Management**
|
||||
- [x] Start/Stop shell scripts (for systems without systemd user services)
|
||||
- [x] `start-sdl-mgr.sh` / `stop-sdl-mgr.sh`
|
||||
- [x] `start-sdl-wkr.sh` / `stop-sdl-wkr.sh`
|
||||
- [ ] Combined systemd service
|
||||
- [ ] `sdl.service` to manage both sdl-mgr and sdl-wkr together
|
||||
- [ ] Support for system-level (sudo) and user-level services
|
||||
- [ ] SDL-MGR auto-installs SDL-WKR
|
||||
- [ ] Manager node becomes worker by default
|
||||
- [ ] Config flag to disable: `install_worker_on_manager: false
|
||||
- [x] Pass HTTP Port to SDL-WKR Curl installer
|
||||
- [ ] Update Install Summary
|
||||
- [ ] Add Quite Mode to sdl-wkr install
|
||||
|
||||
|
||||
|
||||
**Data & Storage**
|
||||
- [ ] Data Storage Organizational Structure
|
||||
- [ ] Define directory hierarchy for tasks, results, logs
|
||||
- [ ] Implement file naming conventions
|
||||
- [ ] Add cleanup/retention policies
|
||||
- [ ] MinIO S3 Storage Server Integration
|
||||
- [ ] Install and configure MinIO
|
||||
- [ ] S3 bucket structure for SDL data
|
||||
- [ ] Worker access to S3 storage
|
||||
- [ ] Web UI S3 browser integration
|
||||
|
||||
**Projects**
|
||||
- [ ] Project Management Org Structure
|
||||
- [ ] Define project metadata schema
|
||||
- [ ] Project lifecycle states (draft, active, archived)
|
||||
- [ ] Permission/access control model
|
||||
- [ ] Experiment Organization under Org Structure
|
||||
- [ ] Experiment templates
|
||||
- [ ] Parameter tracking
|
||||
- [ ] Result linking to experiments
|
||||
- [ ] HPC Experiment Binary Development
|
||||
- [ ] sdl-wkr Capablity detection (phyton, etc)
|
||||
- [ ] Binary packaging format
|
||||
- [ ] Dependency management
|
||||
- [ ] Version control for binaries
|
||||
|
||||
**Dashboard Enhancements**
|
||||
- [x] Dark/Light theme polish (CSS refinements)
|
||||
- [ ] Real-time resource graphs (Chart.js)
|
||||
- [ ] Worker status history timeline
|
||||
- [x] Cluster health indicators
|
||||
- [x] Alert/notification system for worker failures
|
||||
- [ ] Add a Clock to the Dashboard (ISO 8601)
|
||||
- [ ] WOrker Cards instead of table
|
||||
- [ ] Search bar for workers, experiments, projects
|
||||
|
||||
|
||||
**Testing & Documentation**
|
||||
- [ ] Cross-platform testing (Debian, Ubuntu, Arch, Fedora, Win, OSx, BSD)
|
||||
- [ ] Installation documentation
|
||||
- [ ] Configuration examples
|
||||
- [ ] Troubleshooting guide
|
||||
- [ ] Validate IP change Stablity
|
||||
|
||||
---
|
||||
|
||||
# Design
|
||||
|
||||
## Design Goals
|
||||
- Cross Platform (lnx, mac, win, bsd, rpi) Linux First, support for others later.
|
||||
- Node.JS based Manager and Worker code bases
|
||||
- Extreme Minimal OS Dependancies. sdl-mgr self hosts all the necessary code to deploy and coordinate a cluster. Including distrubution of Node binaries.
|
||||
- Automate Update Workflows
|
||||
- Zero Technical Debt (ideal)
|
||||
- Sane handling of sdl-workers comming and going, being added or removed.
|
||||
- Zero sdl-wkr Configuration (ideal) - Install > Run > Listen to UDP Bcast > Auto Join MQTT for cluster command and control.
|
||||
|
||||
### Security
|
||||
- Security considerations TLS, Auth, etc. are future scopes of work. At this stage we are focused on getting the manager and workers up and running as a Minimum Viable Product (MVP) for a cluster of SDL workers. But we are also thinking forward about security and cross platform support so those features will be easy to implement later without a major code refactor.
|
||||
- The current assumption is that SDL runs in a segmented subnet with restricted access. And that we do not have malicious actors trying to compromise the network or poison the open MQTT broker.
|
||||
|
||||
|
||||
# Architecture
|
||||
|
||||
Software Defined Laboratry (SDL)
|
||||
|
||||
## Directory Structure
|
||||
|
||||
### SDL Source Code Structure
|
||||
```
|
||||
../sdl
|
||||
├── docs
|
||||
├── sdl-mgr
|
||||
│ ├── app
|
||||
│ │ └── modules
|
||||
│ │ ├── mqtt
|
||||
│ │ ├── nwa-lib
|
||||
│ │ ├── sdl-mgr
|
||||
│ │ ├── template
|
||||
│ │ └── web
|
||||
│ ├── conf
|
||||
│ │ └── modules
|
||||
│ ├── html
|
||||
│ │ ├── conf
|
||||
│ │ ├── css
|
||||
│ │ ├── img
|
||||
│ │ ├── js
|
||||
│ │ └── md
|
||||
│ └── scripts
|
||||
├── sdl-wkr
|
||||
│ ├── app
|
||||
│ │ └── modules
|
||||
│ │ ├── nwa-lib
|
||||
│ │ ├── sdl-wkr
|
||||
│ │ └── template
|
||||
│ ├── conf
|
||||
│ │ └── modules
|
||||
│ └── scripts
|
||||
└── tools
|
||||
```
|
||||
|
||||
### SDL Installation Structure
|
||||
```
|
||||
~/.sdl/
|
||||
├── conf
|
||||
├── data
|
||||
│ ├── jobs
|
||||
│ └── proj
|
||||
├── dist
|
||||
├── logs
|
||||
├── nodejs
|
||||
│ └── current -> /home/jehaverlack/.sdl/nodejs/node-v22.22.0-linux-x64
|
||||
├── sdl-mgr
|
||||
│ ├── current -> /home/jehaverlack/.sdl/sdl-mgr/sdl-mgr_0.2.7
|
||||
│ └── sdl-mgr_0.2.7
|
||||
│ ├── app
|
||||
│ │ └── modules
|
||||
│ │ ├── mqtt
|
||||
│ │ ├── nwa-lib
|
||||
│ │ ├── sdl-mgr
|
||||
│ │ ├── template
|
||||
│ │ └── web
|
||||
│ ├── conf
|
||||
│ │ └── modules
|
||||
│ ├── html
|
||||
│ │ ├── conf
|
||||
│ │ ├── css
|
||||
│ │ ├── img
|
||||
│ │ ├── js
|
||||
│ │ └── md
|
||||
│ └── scripts
|
||||
└── sdl-wkr
|
||||
```
|
||||
|
||||
|
||||
### SDL Manager Install Script
|
||||
|
||||
```
|
||||
$ git clone https://github.com/jehaverlack/sdl.git
|
||||
$ cd sdl
|
||||
$ ./install-sdl.sh
|
||||
```
|
||||
|
||||
This will install both sdl-mgr and sdl-wkr on the Manager host.
|
||||
|
||||
### SDL Worker Install Script
|
||||
|
||||
Once the sdl-mgr is running, it broadcasts the install command over UDP. On your worker host, run the following command to listen for the install command on your worker.
|
||||
|
||||
```
|
||||
nc -u -l -k 10101 | jq -r '.msg.sdl_wkr_install_cmd[]'
|
||||
```
|
||||
|
||||
> **NOTE**: If you have a firewall, you must allow SDL Worker to listen on UDP port 10101.
|
||||
|
||||
The install command will look like the following, both curl and wget commands are supported.
|
||||
|
||||
```
|
||||
curl -s http://10.0.0.144:8081/dist/install-sdl-wkr.sh | bash -s 10.0.0.144:8081
|
||||
```
|
||||
|
||||
### SDL Worker Auto Update Process
|
||||
|
||||
Once a worker is joined to a manager's MQTT broker, it will automatically update to the latest version of SDL deployed by the manager.
|
||||
|
||||
### Worker Join
|
||||
|
||||
- UDP Bcast
|
||||
- MQTT Join
|
||||
|
||||
### Worker Telemetry
|
||||
|
||||
Workers publish telemetry to the manager MQTT broker. The manager will collect this telemetry and publish to the web UI over MQTT.
|
||||
|
||||
### SDL Storage
|
||||
|
||||
- MinIO S3 Storage Server
|
||||
- this is a non node.js codebase
|
||||
|
||||
```
|
||||
Project
|
||||
└── Experiment
|
||||
├── L0 Raw Results
|
||||
└── L1 Analysis Results
|
||||
```
|
||||
|
||||
```
|
||||
sdl/
|
||||
└── projects/
|
||||
└── <project_id>/
|
||||
└── experiments/
|
||||
└── <experiment_id>/
|
||||
└── runs/
|
||||
└── <run_id>/
|
||||
├── l0/
|
||||
└── l1/
|
||||
```
|
||||
|
||||
# Projects
|
||||
- Need to define structure to manage projects.
|
||||
- Projects will be managed with JSON files
|
||||
- We web UI management Edit interface is nice but will come later. Just need to get things working first.
|
||||
|
||||
## Experiments
|
||||
- Within each Project Scope experiments can be conducted (run) as a specific configuration or permuation of variable to test.
|
||||
- Each experiment represents a set of parrallized jobs for the cluster to execute on.
|
||||
- L0 Raw Results will be collected and stored in a run S3 Bucket.
|
||||
- L1 Analysis Results will be collected and stored in a run S3 Bucket
|
||||
- L1 Analysis represents an other set of parrallized jobs for the cluster to execute on.
|
||||
|
||||
### Run Engine (RE)
|
||||
- Run Engines (RE): May be a Python, Rust, C++, etc. executable/script that is forked by the sdl-wkr Node.JS process.
|
||||
- RE get input configuration from the local sdl-wkr process.
|
||||
- RE communicate only with the sdl-wkr process.
|
||||
- But RE processes may read and write direclty to S3
|
||||
- the sdl-wkr process communicates with the SDL Manager (sdl-mgr) process to notify on status of jobs.
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
|
||||
# Software Defined Laboratory (SDL)
|
||||
|
||||
> NOTE: This is a work in progress. SDL has only been tested on Debian based
|
||||
> Linux systems. But has been designed to be cross platform. So running on
|
||||
> other platforms should not be a heavy lift.
|
||||
|
||||
Other than basic OS tools, SDL downloads all dependencies idempotently and installs
|
||||
everything into a single SDL home directory (~/.sdl).
|
||||
|
||||
---
|
||||
|
||||
## SDL Manager (sdl-mgr)
|
||||
|
||||
#### Installation
|
||||
|
||||
```
|
||||
git clone <SDL_REPO_URL>
|
||||
```
|
||||
|
||||
```
|
||||
cd sdl
|
||||
```
|
||||
|
||||
```
|
||||
./install-sdl.sh
|
||||
```
|
||||
|
||||
#### Upgrading
|
||||
|
||||
```
|
||||
cd sdl
|
||||
```
|
||||
|
||||
```
|
||||
git pull origin main
|
||||
```
|
||||
|
||||
```
|
||||
./install-sdl.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SDL Worker (sdl-wkr)
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
(Debian, Ubuntu, Linux Mint, Zorin OS, Raspbian)
|
||||
|
||||
```
|
||||
sudo apt -y install gawk coreutils curl jq grep sed tar unzip netcat-openbsd
|
||||
```
|
||||
|
||||
#### Installation
|
||||
|
||||
(Linux, OSX, Windows(WSL2))
|
||||
|
||||
Listen to UDP Broadcast on port 10101
|
||||
- To get the **sld-wkr** curl or wget install command
|
||||
|
||||
```
|
||||
nc -u -l -k 10101
|
||||
```
|
||||
|
||||
OR
|
||||
|
||||
```
|
||||
nc -u -l -k 10101 |jq
|
||||
{
|
||||
"ts": "2026-01-31T05:20:31.815Z",
|
||||
"sdl_id": "906a027c-52f0-4cbb-b3e0-5eb84856d50a",
|
||||
"role": "sdl-mgr",
|
||||
"host": "haverlab-sld-mgr",
|
||||
"type": "udp-beacon",
|
||||
"msg": {
|
||||
"info": {
|
||||
"desc": "Software Defined Laboratory: Manager (sdl-mgr)",
|
||||
"version": "0.2.10",
|
||||
"copyright": "2026 John Haverlack"
|
||||
},
|
||||
"cluster": {
|
||||
"id": "haverlab",
|
||||
"name": "Haverlab",
|
||||
"desc": "John Haverlack's Home Lab"
|
||||
},
|
||||
"mqtt": {
|
||||
"host": "10.0.0.178",
|
||||
"port": 1883
|
||||
},
|
||||
"web": {
|
||||
"proto": "http",
|
||||
"host": "10.0.0.178",
|
||||
"port": 8081,
|
||||
"api_config": "/api/config",
|
||||
"config_url": "http://10.0.0.178:8081/api/config"
|
||||
},
|
||||
"sdl_wkr_install_cmd": {
|
||||
"curl": "curl -s http://10.0.0.178:8081/dist/install-sdl-wkr.sh | bash -s 10.0.0.178",
|
||||
"wget": "wget -O - http://10.0.0.178:8081/dist/install-sdl-wkr.sh | bash -s 10.0.0.178"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
curl -s http://<SDL_MGR_IP>:<WEB_PORT>/dist/install-sdl-wkr.sh | bash -s <SDL_MGR_IP>
|
||||
```
|
||||
|
||||
#### Upgrading
|
||||
|
||||
SDL Workers continuously monitor the SDL Manager’s advertised version via MQTT cluster status messages and will automatically re-install and restart when a newer version is detected.
|
||||
|
||||
Alternatively you can upgrade manually by rerunning the install-sdl-wkr.sh script.
|
||||
|
||||
```
|
||||
curl -s http://<SDL_MGR_IP>:<WEB_PORT>/dist/install-sdl-wkr.sh | bash -s <SDL_MGR_IP>
|
||||
```
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
# Developer Workflow
|
||||
|
||||
## 1. Start from clean main branch
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull
|
||||
```
|
||||
|
||||
Ensure no local changes
|
||||
|
||||
```bash
|
||||
git status -s
|
||||
```
|
||||
|
||||
## 2. Generate new branch
|
||||
|
||||
```bash
|
||||
./tools/new-version.sh
|
||||
```
|
||||
|
||||
## 3. Commit changes
|
||||
|
||||
```bash
|
||||
git add <files>
|
||||
git commit -m "feat: <description>"
|
||||
```
|
||||
|
||||
## 4. Update Changelog
|
||||
|
||||
```bash
|
||||
./tools/changelog-update.sh
|
||||
```
|
||||
|
||||
**Optionally**: Update changelog.json Summary and Notes
|
||||
|
||||
Generate CHANGELOG.md
|
||||
|
||||
```bash
|
||||
./tools/genmd-changelog.sh
|
||||
```
|
||||
|
||||
## 5. Merge into main
|
||||
|
||||
```bash
|
||||
./tools/main-merge.sh
|
||||
```
|
||||
|
||||
## 6. Push to GitHub
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
|
||||
## 7. Push Tags to GitHub
|
||||
|
||||
```bash
|
||||
git push origin --tags
|
||||
```
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "Project Name",
|
||||
"desc": "Project Description",
|
||||
"parameters" : {
|
||||
"parm1": {
|
||||
"name": "Parameter Name",
|
||||
"desc": "Parameter Description",
|
||||
"type": "integer",
|
||||
"min": 0,
|
||||
"max": 100
|
||||
},
|
||||
"parm2": {
|
||||
"name": "Parameter Name",
|
||||
"desc": "Parameter Description",
|
||||
"type": "integer",
|
||||
"min": 0,
|
||||
"max": 100
|
||||
}
|
||||
},
|
||||
"iterations": 1000
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"id": "nks-ca",
|
||||
"name": "NKS 1D CA",
|
||||
"desc": "Exploration of 1D elementary New Kind of Science (NKS) Cellular Automata (CA)",
|
||||
"date": "2026-02-09",
|
||||
"version": "0.0.0",
|
||||
"parameters" : {
|
||||
"x": {
|
||||
"name": "X Cells",
|
||||
"desc": "Number of Binary Cells on the X Dimension",
|
||||
"type": "integer",
|
||||
"min": 3,
|
||||
"max": 15
|
||||
},
|
||||
"rule": {
|
||||
"name": "Rule",
|
||||
"desc": "Update Rule Index",
|
||||
"type": "integer",
|
||||
"min": 0,
|
||||
"max": 255
|
||||
},
|
||||
"init": {
|
||||
"name": "Initial State",
|
||||
"desc": "Initial Binary State",
|
||||
"type": "integer",
|
||||
"min": 0,
|
||||
"max": "2^x"
|
||||
}
|
||||
},
|
||||
"iterations": 100
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"id": "ssl-evo-rnd",
|
||||
"name": "SSL Random Evolution",
|
||||
"desc": "State Space Locality (SSL) Random Perturbation Evolution Update Rule",
|
||||
"date": "2026-02-09",
|
||||
"version": "0.0.0",
|
||||
"parameters" : {
|
||||
"N": {
|
||||
"name": "Nodes",
|
||||
"desc": "N Node Directional Graph",
|
||||
"type": "integer",
|
||||
"min": 1,
|
||||
"max": 1000
|
||||
},
|
||||
"rule": {
|
||||
"name": "Rule",
|
||||
"desc": "Proximity Update Rule",
|
||||
"type": "text",
|
||||
"eunum": [
|
||||
"Pii",
|
||||
"Poo",
|
||||
"Pio",
|
||||
"Poi"
|
||||
]
|
||||
},
|
||||
"init": {
|
||||
"name": "Initial State",
|
||||
"desc": "NxN Adjacency Matrix",
|
||||
"type": "matrix"
|
||||
}
|
||||
},
|
||||
"iterations": 1000,
|
||||
"permutations": {}
|
||||
}
|
||||
|
|
@ -0,0 +1,437 @@
|
|||
#!/usr/bin/env bash
|
||||
# install.sh - NWA Installer Script
|
||||
set -euo pipefail
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Functions
|
||||
# ------------------------------------------------------------
|
||||
|
||||
resolve_dirs() {
|
||||
local json="$1"
|
||||
declare -A raw resolved
|
||||
|
||||
while IFS='=' read -r key val; do
|
||||
raw["$key"]="$val"
|
||||
done < <(jq -r '.DIRS | to_entries[] | "\(.key)=\(.value)"' "$json")
|
||||
|
||||
if [[ -z "${raw[HOME]:-}" ]]; then
|
||||
echo "ERROR: DIRS.HOME is not defined in config.json"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
resolved["HOME"]="${raw[HOME]/#\~/$HOME}"
|
||||
|
||||
local changed=true
|
||||
while $changed; do
|
||||
changed=false
|
||||
for key in "${!raw[@]}"; do
|
||||
[[ -n "${resolved[$key]:-}" ]] && continue
|
||||
|
||||
local val="${raw[$key]}"
|
||||
for rkey in "${!resolved[@]}"; do
|
||||
val="${val/$rkey/${resolved[$rkey]:-}}"
|
||||
done
|
||||
|
||||
if [[ "$val" != ** ]]; then
|
||||
resolved["$key"]="$val"
|
||||
changed=true
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
for key in "${!raw[@]}"; do
|
||||
[[ -z "${resolved[$key]:-}" ]] && {
|
||||
echo "ERROR: Unresolved DIR: $key=${raw[$key]}"
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
for key in "${!resolved[@]}"; do
|
||||
export "$key=${resolved[$key]}"
|
||||
printf " %-12s %s\n" "$key:" "${resolved[$key]}"
|
||||
mkdir -p "${resolved[$key]}"
|
||||
done
|
||||
}
|
||||
|
||||
use_systemd() {
|
||||
# Check if systemd user mode is available and service exists
|
||||
if systemctl --user status >/dev/null 2>&1; then
|
||||
return 0 # Use systemd
|
||||
fi
|
||||
return 1 # Use manual scripts
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Main
|
||||
# ------------------------------------------------------------
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Require non root user
|
||||
# ------------------------------------------------------------
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
echo "ERROR: install.sh must be run as a non-root user"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Requirements
|
||||
# ------------------------------------------------------------
|
||||
for cmd in awk curl grep jq nc sed sha256sum tar unzip; do
|
||||
command -v "$cmd" >/dev/null || {
|
||||
echo "ERROR: $cmd is required"
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Resolve ROOT
|
||||
# ------------------------------------------------------------
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$SCRIPT_DIR" && pwd)"
|
||||
|
||||
CONFIG_FILE="$ROOT/config.json"
|
||||
META_FILE="$ROOT/metadata.json"
|
||||
|
||||
[[ -f "$CONFIG_FILE" ]] || { echo "ERROR: config.json not found"; exit 1; }
|
||||
[[ -f "$META_FILE" ]] || { echo "ERROR: metadata.json not found"; exit 1; }
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Hostname detection
|
||||
# ------------------------------------------------------------
|
||||
HOSTNAME="$(hostname)"
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# OS / platform detection
|
||||
# ------------------------------------------------------------
|
||||
OS_ID="unknown"
|
||||
OS_LIKE=""
|
||||
OS_NAME=""
|
||||
OS_VERSION=""
|
||||
OS_FAMILY="unknown"
|
||||
OS_ARCH="unknown"
|
||||
OS_PLATFORM="unknown"
|
||||
|
||||
if [[ -f /etc/os-release ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
OS_ID="${ID:-unknown}"
|
||||
OS_LIKE="${ID_LIKE:-}"
|
||||
OS_NAME="${NAME:-unknown}"
|
||||
OS_VERSION="${VERSION_ID:-}"
|
||||
fi
|
||||
|
||||
case "$OS_ID" in
|
||||
debian|ubuntu|raspbian|zorin)
|
||||
OS_FAMILY="debian"
|
||||
OS_PLATFORM="linux"
|
||||
;;
|
||||
rhel|rocky|almalinux|centos|fedora)
|
||||
OS_FAMILY="rhel"
|
||||
OS_PLATFORM="linux"
|
||||
;;
|
||||
*)
|
||||
if [[ "$OS_LIKE" == *debian* ]]; then
|
||||
OS_FAMILY="debian"
|
||||
elif [[ "$OS_LIKE" == *rhel* ]]; then
|
||||
OS_FAMILY="rhel"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# CPU architecture detection
|
||||
# ------------------------------------------------------------
|
||||
ARCH_RAW="$(uname -m)"
|
||||
|
||||
case "$ARCH_RAW" in
|
||||
x86_64) OS_ARCH="x64" ;;
|
||||
aarch64|arm64) OS_ARCH="arm64" ;;
|
||||
armv7l) OS_ARCH="armv7l" ;;
|
||||
*)
|
||||
echo "ERROR: Unsupported architecture: $ARCH_RAW"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
PLATFORM="${OS_FAMILY}:${OS_ARCH}"
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# SELinux detection (warn-only)
|
||||
# ------------------------------------------------------------
|
||||
SELINUX_MODE="disabled"
|
||||
|
||||
if command -v getenforce >/dev/null 2>&1; then
|
||||
SELINUX_MODE="$(getenforce | tr '[:upper:]' '[:lower:]')"
|
||||
fi
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Setting variables
|
||||
# ------------------------------------------------------------
|
||||
|
||||
VERSION="$(jq -r '.METADATA.version' "$META_FILE")"
|
||||
VERSION_DATE="$(jq -r '.METADATA.version_date' "$META_FILE")"
|
||||
COPYRIGHT="$(jq -r '.METADATA.copyright' "$META_FILE")"
|
||||
AUTHOR="$(jq -r '.METADATA.author' "$META_FILE")"
|
||||
LICENSE="$(jq -r '.METADATA.license' "$META_FILE")"
|
||||
HOMEPAGE="$(jq -r '.METADATA.homepage' "$META_FILE")"
|
||||
REPO="$(jq -r '.METADATA.git_repo' "$META_FILE")"
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# NodeJS detection
|
||||
# ------------------------------------------------------------
|
||||
NODE_VERSION="$(jq -r '.NODEJS.VERSION' "$CONFIG_FILE")"
|
||||
NODE_PLATFORMS="$(jq -r '.NODEJS.TARGETS[]' "$CONFIG_FILE" | tr '\n' ' ')"
|
||||
NODE_PLATFORMS="${NODE_PLATFORMS%% }"
|
||||
|
||||
|
||||
echo "####################################"
|
||||
echo "Node Web App (NWA)"
|
||||
echo "####################################"
|
||||
echo " Version: $VERSION"
|
||||
echo " Version Date: $VERSION_DATE"
|
||||
echo " Copyright: (C) $COPYRIGHT"
|
||||
echo " Author: $AUTHOR"
|
||||
echo " License: $LICENSE"
|
||||
echo " Homepage: $HOMEPAGE"
|
||||
echo " Repository: $REPO"
|
||||
echo ""
|
||||
|
||||
echo "NodeJS:"
|
||||
echo " Version: $NODE_VERSION"
|
||||
echo " Platforms: "
|
||||
for np in $NODE_PLATFORMS; do
|
||||
echo " $np"
|
||||
done
|
||||
echo ""
|
||||
|
||||
echo "Target System:"
|
||||
echo " Hostname: $HOSTNAME"
|
||||
echo " OS: $OS_NAME $OS_VERSION"
|
||||
echo " Family: $OS_FAMILY"
|
||||
echo " Platform: $OS_PLATFORM"
|
||||
echo " Arch: $OS_ARCH"
|
||||
echo " systemd: user services available"
|
||||
echo " SELinux: $SELINUX_MODE"
|
||||
echo ""
|
||||
|
||||
if [[ "$OS_PLATFORM" == "win32" ]]; then
|
||||
echo "ERROR: win32 host install not supported by this installer"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Directories:"
|
||||
echo " ROOT: $ROOT"
|
||||
resolve_dirs "$CONFIG_FILE"
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Build sdl-mgr tarball to DIST
|
||||
# Always rebuild sdl-mgr_version.tgz from source
|
||||
# ------------------------------------------------------------
|
||||
echo ""
|
||||
echo "Generating: $DIST/sdl-mgr_$VERSION.tgz"
|
||||
tar -czf "$DIST/sdl-mgr_$VERSION.tgz" -C "$ROOT" sdl-mgr
|
||||
sha256sum "$DIST/sdl-mgr_$VERSION.tgz" | awk '{sub(".*/","",$2); print}' > "$DIST/sdl-mgr_$VERSION.tgz.sha256"
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Build sdl-wkr tarball to DIST
|
||||
# Always rebuild sdl-wkr_version.tgz from source
|
||||
# ------------------------------------------------------------
|
||||
echo ""
|
||||
echo "Generating: $DIST/sdl-wkr_$VERSION.tgz"
|
||||
tar -czf "$DIST/sdl-wkr_$VERSION.tgz" -C "$ROOT" sdl-wkr
|
||||
sha256sum "$DIST/sdl-wkr_$VERSION.tgz" | awk '{sub(".*/","",$2); print}' > "$DIST/sdl-wkr_$VERSION.tgz.sha256"
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Copy install-sdl-wkr.sh to DIST
|
||||
# ------------------------------------------------------------
|
||||
echo ""
|
||||
echo "Copying: $DIST/install-sdl-wkr.sh"
|
||||
cp "$ROOT/sdl-wkr/scripts/install-sdl-wkr.sh" "$DIST/install-sdl-wkr.sh"
|
||||
sha256sum "$DIST/install-sdl-wkr.sh" | awk '{sub(".*/","",$2); print}' > "$DIST/install-sdl-wkr.sh.sha256"
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Fetch NodeJS Tarballs / Zips to DIST
|
||||
# ------------------------------------------------------------
|
||||
echo ""
|
||||
echo "Fetching NodeJS Binaries to $DIST"
|
||||
SHASUM_URL="https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt"
|
||||
curl -sSL "$SHASUM_URL" -o "$DIST/NODE_SHASUMS256.txt"
|
||||
|
||||
# Download NodeJS tarball
|
||||
|
||||
for np in $NODE_PLATFORMS; do
|
||||
IFS=":" read -r OS ARCH <<< "$np"
|
||||
|
||||
case "$OS" in
|
||||
linux) EXT="tar.xz" ;;
|
||||
darwin) EXT="tar.gz" ;;
|
||||
win) EXT="zip" ;;
|
||||
*) echo "ERROR: Unknown NodeJS target OS: $OS"; exit 1 ;;
|
||||
esac
|
||||
|
||||
PLATFORM="${OS}-${ARCH}"
|
||||
NODE_DIR="node-v${NODE_VERSION}-${PLATFORM}"
|
||||
NODE_TARBALL="${NODE_DIR}.$EXT"
|
||||
NODE_URL="https://nodejs.org/dist/v${NODE_VERSION}/${NODE_TARBALL}"
|
||||
|
||||
echo " $np: $NODE_TARBALL"
|
||||
|
||||
if [ ! -e "$DIST/$NODE_TARBALL" ]; then
|
||||
|
||||
curl -sSL "$NODE_URL" -o "$DIST/$NODE_TARBALL"
|
||||
|
||||
EXPECTED_SHA="$(grep " $NODE_TARBALL\$" "$DIST/NODE_SHASUMS256.txt" | awk '{print $1}')"
|
||||
|
||||
if [[ -z "$EXPECTED_SHA" ]]; then
|
||||
echo "Error: SHA256 not found for $NODE_TARBALL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ACTUAL_SHA="$(sha256sum "$DIST/$NODE_TARBALL" | awk '{print $1}')"
|
||||
|
||||
if [[ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]]; then
|
||||
echo "SHA mismatch $NODE_TARBALL"
|
||||
rm -f "$DIST/$NODE_TARBALL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$EXPECTED_SHA $NODE_TARBALL" > "$DIST/$NODE_TARBALL.sha256"
|
||||
fi
|
||||
done
|
||||
|
||||
unset OS ARCH
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Extract sdl-mgr-VERSION tarball from DIST to MGR
|
||||
# Symlink to MGR/current
|
||||
# ------------------------------------------------------------
|
||||
echo ""
|
||||
echo "Extracting: $DIST/sdl-mgr_$VERSION.tgz to $MGR/sdl-mgr_$VERSION"
|
||||
rm -rf "$MGR/sdl-mgr_$VERSION"
|
||||
tar -xzf "$DIST/sdl-mgr_$VERSION.tgz" -C "$MGR"
|
||||
mv "$MGR/sdl-mgr" "$MGR/sdl-mgr_$VERSION"
|
||||
# rm "$MGR/current"
|
||||
ln -sfn "$MGR/sdl-mgr_$VERSION" "$MGR/current"
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Copy MGR/current/conf/efault-sdl-mgr-config.json to CONF/sdl-mgr-config.json
|
||||
# ------------------------------------------------------------
|
||||
|
||||
if [ ! -e "$CONF/sdl-mgr-config.json" ]; then
|
||||
echo ""
|
||||
echo "Copying: $MGR/current/conf/default-sdl-mgr-config.json to $CONF/sdl-mgr-config.json"
|
||||
cp "$MGR/current/conf/default-sdl-mgr-config.json" "$CONF/sdl-mgr-config.json"
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Extract NodeJS tarball from DIST to NODEJS
|
||||
# Symlink to NODEJS/current
|
||||
# ------------------------------------------------------------
|
||||
|
||||
# Check if NodeJS is already installed
|
||||
NODE_BIN="$NODEJS/current/bin/node"
|
||||
NPM_BIN="$NODEJS/current/bin/npm"
|
||||
# echo "NodeJS binary: $NODE_BIN"
|
||||
|
||||
if [ -e "$NODE_BIN" ]; then
|
||||
NODE_BIN_VERSION="$("$NODE_BIN" --version)"
|
||||
# echo "NodeJS bin version: $NODE_BIN_VERSION"
|
||||
# echo "NodeJS version: v$NODE_VERSION"
|
||||
case "$OS_PLATFORM" in
|
||||
linux) EXT="tar.xz"; TAR_OPTS="-xJf" ;;
|
||||
darwin) EXT="tar.gz"; TAR_OPTS="-xzf" ;;
|
||||
win) EXT="zip" ;;
|
||||
*) echo "ERROR: Unsupported OS platform: $OS_PLATFORM"; exit 1 ;;
|
||||
esac
|
||||
|
||||
if [ "$NODE_BIN_VERSION" != "v$NODE_VERSION" ]; then
|
||||
echo ""
|
||||
echo "Installing NodeJS: node-v${NODE_VERSION}-${OS_PLATFORM}-${OS_ARCH}"
|
||||
|
||||
echo "Extracting: $DIST/node-v${NODE_VERSION}-${OS_PLATFORM}-${OS_ARCH}.$EXT"
|
||||
if [ $OS_PLATFORM == "win32" ]; then
|
||||
echo "ERROR: win32 host install not supported by this installer"
|
||||
exit 1
|
||||
# unzip "$DIST/node-v${NODE_VERSION}-${OS_PLATFORM}-${OS_ARCH}.$EXT" -d "$NODEJS"
|
||||
else
|
||||
tar $TAR_OPTS "$DIST/node-v${NODE_VERSION}-${OS_PLATFORM}-${OS_ARCH}.$EXT" -C "$NODEJS"
|
||||
fi
|
||||
# rm "$NODEJS/current"
|
||||
ln -sfn "$NODEJS/node-v${NODE_VERSION}-${OS_PLATFORM}-${OS_ARCH}" "$NODEJS/current"
|
||||
else
|
||||
echo ""
|
||||
echo "NodeJS $OS_PLATFORM-$OS_ARCH v$NODE_VERSION already installed"
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo "Installing NodeJS $NODE_VERSION"
|
||||
|
||||
echo "Extracting: $DIST/node-v${NODE_VERSION}-${OS_PLATFORM}-${OS_ARCH}.tar.xz"
|
||||
tar -xJf "$DIST/node-v${NODE_VERSION}-${OS_PLATFORM}-${OS_ARCH}.tar.xz" -C "$NODEJS"
|
||||
ln -sfn "$NODEJS/node-v${NODE_VERSION}-${OS_PLATFORM}-${OS_ARCH}" "$NODEJS/current"
|
||||
fi
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Install sdl-mgr Node Dependencies
|
||||
# ------------------------------------------------------------
|
||||
echo ""
|
||||
echo "Installing NodeJS Dependencies"
|
||||
cd "$MGR/current/app"
|
||||
export PATH="$NODEJS/current/bin:$PATH"
|
||||
$NPM_BIN install
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Copy Start / Stop Scripts
|
||||
# ------------------------------------------------------------
|
||||
mkdir -p "$HOME/scripts/"
|
||||
cp "$MGR/current/scripts/start-sdl-mgr.sh" "$HOME/scripts/"
|
||||
cp "$MGR/current/scripts/stop-sdl-mgr.sh" "$HOME/scripts/"
|
||||
cp "$MGR/current/scripts/status-sdl-mgr.sh" "$HOME/scripts/"
|
||||
chmod u+x "$HOME/scripts/"*
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Install Systemd Unit Files
|
||||
# Copy MGR/current/scripts/sdl-mgr.service to ~/.config/systemd/user
|
||||
# ------------------------------------------------------------
|
||||
if use_systemd; then
|
||||
echo ""
|
||||
echo "Installing SystemD Unit Files"
|
||||
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cp "$MGR/current/scripts/sdl-mgr.service" ~/.config/systemd/user/
|
||||
|
||||
"${HOME}/scripts/stop-sdl-mgr.sh"
|
||||
sleep 1
|
||||
systemctl --user daemon-reload --no-pager
|
||||
fi
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Start SDL Manager
|
||||
# ------------------------------------------------------------
|
||||
"${HOME}/scripts/start-sdl-mgr.sh"
|
||||
sleep 2
|
||||
"${HOME}/scripts/status-sdl-mgr.sh"
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo ""
|
||||
echo "ERROR: SDL Manager failed to start"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
#
|
||||
|
||||
|
||||
echo ""
|
||||
echo "SDL Manager deployed:"
|
||||
echo " to: $HOME"
|
||||
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"METADATA": {
|
||||
"name": "nwa",
|
||||
"description": "Node Web App",
|
||||
"abbr": "NWA",
|
||||
"version": "0.1.0",
|
||||
"version_date": "2026-02-08",
|
||||
"maturity": "PROD",
|
||||
"author": "John Haverlack",
|
||||
"copyright": "2026 John Haverlack",
|
||||
"license": "MIT",
|
||||
"license_url": "https://opensource.org/license/MIT",
|
||||
"homepage": "https://github.com/jehaverlack/sdl",
|
||||
"git_repo": "https://github.com/jehaverlack/sdl.git"
|
||||
},
|
||||
"META_MAPS": {
|
||||
"package.json": {
|
||||
"version": "version",
|
||||
"version_date": "version_date",
|
||||
"author": "author",
|
||||
"copyright": "copyright",
|
||||
"license": "license",
|
||||
"license_url": "license_url",
|
||||
"homepage": "homepage"
|
||||
},
|
||||
"*.md": {
|
||||
"**Version**": "version",
|
||||
"**Date**": "version_date",
|
||||
"**Author**": "author",
|
||||
"**Copyright**": "copyright",
|
||||
"**License**": "license",
|
||||
"**Homepage**": "homepage"
|
||||
}
|
||||
},
|
||||
"MANIFEST": [
|
||||
"README.md",
|
||||
"docs/DESIGN.md",
|
||||
"sdl-mgr/app/package.json",
|
||||
"sdl-wkr/app/package.json"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
conf/secrets/*
|
||||
app/node_modules/*
|
||||
data/*
|
||||
logs/*
|
||||
**/.~lock*#
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# LICENSE: MIT
|
||||
|
||||
Copyright 2025 John Haverlack
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
# node-web-app
|
||||
Node.js Web Application
|
||||
|
||||
# Purpose
|
||||
|
||||
This web application template provides a modular template for building a node.js web application with an API and UI.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
node-web-app
|
||||
├── app
|
||||
│ └── modules
|
||||
│ ├── template
|
||||
│ └── web
|
||||
├── conf
|
||||
│ ├── modules
|
||||
│ └── secrets
|
||||
├── data
|
||||
│ └── sqlite3
|
||||
├── docs
|
||||
├── html
|
||||
│ ├── conf
|
||||
│ ├── css
|
||||
│ ├── img
|
||||
│ ├── js
|
||||
│ └── md
|
||||
└── logs
|
||||
```
|
||||
|
||||
|
||||
|
||||
# Getting Started
|
||||
|
||||
## Pre-requisites
|
||||
- [Node.js](https://nodejs.org/en) 22.x
|
||||
|
||||
### Optional for Development
|
||||
- [DB Browser for SQLite](https://sqlitebrowser.org/]
|
||||
- To view data in **data/sqlite/nwa.db**
|
||||
|
||||
## Clone Repo
|
||||
|
||||
```
|
||||
git clone
|
||||
```
|
||||
|
||||
## Install Dependencies
|
||||
|
||||
```
|
||||
cd node-web-app/app
|
||||
```
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
## Starting the application
|
||||
|
||||
Ensure you are in the `node-web-app/app` directory
|
||||
|
||||
```
|
||||
npm start
|
||||
```
|
||||
|
||||
```
|
||||
$ node-web-app/app$ npm start
|
||||
|
||||
> node-web-app@1.0.0 start
|
||||
> node index.js
|
||||
|
||||
web: INFO: WebUI running on 127.0.0.1:8080
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
// Node Web Application (NWA)
|
||||
// John Haverlack <john@haverlack.net>
|
||||
// https://github.com/
|
||||
// LICENSE: MIT
|
||||
// Copyright (c) 2025 John Haverlack
|
||||
|
||||
import { load_config, log } from './modules/nwa-lib/index.js';
|
||||
|
||||
const config = load_config();
|
||||
|
||||
log('=======================================================================================');
|
||||
log(config.package.name + ': STARTING: ' + config.package.description + ' v' + config.package.version);
|
||||
// log(JSON.stringify(config, null, 2));
|
||||
// log(JSON.stringify(config.dirs, null, 2), false);
|
||||
|
||||
for (let m in config.modules) {
|
||||
// log(`Loading module: ${m}`);
|
||||
await import(config.modules[m].main);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,614 @@
|
|||
// Node Web App (NWA) Library Module
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import { fileURLToPath } from 'url';
|
||||
import crypto from 'crypto';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { get } from 'http';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// console.log("DEBUG: __dirname: " + __dirname);
|
||||
|
||||
// Process start time (set once per Node.js process)
|
||||
const PROCESS_START_TS = Date.now();
|
||||
|
||||
const config = load_config();
|
||||
|
||||
function load_config() {
|
||||
let config = {};
|
||||
|
||||
const argv = yargs(hideBin(process.argv))
|
||||
.usage('Usage: npm start -- <options>')
|
||||
.option('configfile', {
|
||||
alias: 'c',
|
||||
description: 'Path to config file',
|
||||
type: 'string'
|
||||
})
|
||||
.help()
|
||||
.alias('help', 'h')
|
||||
.argv;
|
||||
|
||||
// console.log("DEBUG: dirname: " + __dirname);
|
||||
let home = path.join(__dirname, '..', '..', '..'); // HOME Default
|
||||
let mgr_cfg_file = path.join(home, 'conf', 'nwa-config.json');
|
||||
|
||||
// console.log("DEBUG: HOME: " + home);
|
||||
// console.log("DEBUG: MGR_CFG_FILE: " + mgr_cfg_file);
|
||||
try {
|
||||
fs.accessSync(mgr_cfg_file, fs.constants.R_OK);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// // Load from ENV
|
||||
// if (process.env.HOME) {
|
||||
// home = process.env.HOME;
|
||||
// mgr_cfg_file = path.join(home, 'conf', '-mgr-config.json');
|
||||
// // console.log("DEBUG3: HOME: " + home);
|
||||
// // console.log("DEBUG3: MGR_CFG_FILE: " + mgr_cfg_file);
|
||||
// try {
|
||||
// fs.accessSync(mgr_cfg_file, fs.constants.R_OK);
|
||||
// } catch (err) {
|
||||
// console.error(err);
|
||||
// process.exit(1);
|
||||
// }
|
||||
// }
|
||||
|
||||
// // Load from ARGV
|
||||
// if (argv.configfile) {
|
||||
// home = path.dirname(argv.configfile);
|
||||
// mgr_cfg_file = path.join(home, 'conf', '-mgr-config.json');
|
||||
// // console.log("DEBUG4: HOME: " + home);
|
||||
// // console.log("DEBUG4: MGR_CFG_FILE: " + mgr_cfg_file);
|
||||
// try {
|
||||
// fs.accessSync(mgr_cfg_file, fs.constants.R_OK);
|
||||
// } catch (err) {
|
||||
// console.error(err);
|
||||
// process.exit(1);
|
||||
// }
|
||||
// }
|
||||
|
||||
// console.log("DEBUG: HOME: " + home);
|
||||
// console.log("DEBUG: MGR_CFG_FILE: " + mgr_cfg_file);
|
||||
|
||||
|
||||
|
||||
// Load Config
|
||||
try {
|
||||
config = JSON.parse(fs.readFileSync(mgr_cfg_file, 'utf8'));
|
||||
config.dirs.base = config.dirs.base + '/..'
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
// console.log("DEBUG: home: " + home);
|
||||
|
||||
// Expand Data Directory
|
||||
let search_replace = {}
|
||||
search_replace['DIRNAME'] = __dirname
|
||||
// search_replace['HOME'] = home
|
||||
config.dirs['home'] = home
|
||||
for (let d in config.dirs) {
|
||||
search_replace[d.toUpperCase()] = d
|
||||
}
|
||||
|
||||
for (let d in config.dirs) {
|
||||
for (let s in search_replace) {
|
||||
switch (s) {
|
||||
case 'DIRNAME':
|
||||
config.dirs[d] = path.join(config.dirs[d].replace(s, __dirname))
|
||||
break
|
||||
default:
|
||||
config.dirs[d] = path.join(config.dirs[d].replace(s, config.dirs[search_replace[s]]))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Replace DIRNAMES for metadata objects
|
||||
for (let m in config.metadata) {
|
||||
for (let s in search_replace) {
|
||||
switch (s) {
|
||||
case 'DIRNAME':
|
||||
config.metadata[m] = path.join(config.metadata[m].replace(s, __dirname))
|
||||
break
|
||||
default:
|
||||
config.metadata[m] = path.join(config.metadata[m].replace(s, config.dirs[search_replace[s]]))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure all directories exist
|
||||
for (let d in config.dirs) {
|
||||
if (!fs.existsSync(config.dirs[d])) {
|
||||
fs.mkdirSync(config.dirs[d], { recursive: true });
|
||||
}
|
||||
}
|
||||
// for (let k in config.dependencies[d]) {
|
||||
// // if k matches case insensitive "dependencies" the remove elemend k.
|
||||
// if (k.match(/[Dd]ependencies/)) {
|
||||
// delete config.dependencies[d][k]
|
||||
// }
|
||||
|
||||
// if (k.match(/scripts/)) {
|
||||
// delete config.dependencies[d][k]
|
||||
// }
|
||||
// }
|
||||
|
||||
// Load Package Info
|
||||
config.package = JSON.parse(fs.readFileSync(path.join(config.dirs.app, 'package.json'), 'utf8'));
|
||||
config.dependencies = {}
|
||||
|
||||
// Version
|
||||
config.cluster.version = config.package.version
|
||||
|
||||
for (let d in config.package.dependencies) {
|
||||
config.dependencies[d] = JSON.parse(fs.readFileSync(path.join(config.dirs.app, 'node_modules', d, 'package.json'), 'utf8'));
|
||||
}
|
||||
|
||||
// -------------------------------
|
||||
// Host Metadata (static)
|
||||
// -------------------------------
|
||||
config.host = {
|
||||
hostname: os.hostname(),
|
||||
ips: [],
|
||||
|
||||
os: {
|
||||
platform: null,
|
||||
id: null,
|
||||
name: null,
|
||||
version: null,
|
||||
pretty_name: null
|
||||
, },
|
||||
|
||||
cpu: {
|
||||
arch: os.arch(), // x64, arm64, etc.
|
||||
model: null,
|
||||
cores_logical: 0
|
||||
},
|
||||
|
||||
memory: {
|
||||
total_gb: Math.round(os.totalmem() / (1024 ** 3))
|
||||
},
|
||||
|
||||
gpu: {
|
||||
},
|
||||
|
||||
network: {}
|
||||
|
||||
};
|
||||
|
||||
config.nwa = {
|
||||
version: config.package.version
|
||||
}
|
||||
|
||||
config.identity = {
|
||||
id: null,
|
||||
created_at: null,
|
||||
hostname: null
|
||||
}
|
||||
|
||||
// console.log('config: host:', JSON.stringify(config.host, null, 2));
|
||||
|
||||
config.host.gpu = detectGPUs();
|
||||
|
||||
// Get Host IP Addresses
|
||||
const networkInterfaces = os.networkInterfaces();
|
||||
// console.log(JSON.stringify(networkInterfaces, null, 2));
|
||||
config.host.ips = [];
|
||||
for (const interfaceName in networkInterfaces) {
|
||||
config.host.network[interfaceName] = networkInterfaces[interfaceName];
|
||||
|
||||
const addresses = networkInterfaces[interfaceName];
|
||||
for (const address of addresses) {
|
||||
if (address.family === 'IPv4' && !address.internal) {
|
||||
config.host.ips.push(address.address);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config.host.ips.push('127.0.0.1');
|
||||
|
||||
// -------------------------------
|
||||
// CPU Info
|
||||
// -------------------------------
|
||||
const cpus = os.cpus();
|
||||
// console.log('cpus: ', JSON.stringify(cpus, null, 2));
|
||||
if (cpus.length > 0) {
|
||||
config.host.cpu.model = cpus[0].model;
|
||||
config.host.cpu.cores_logical = cpus.length;
|
||||
}
|
||||
|
||||
config.host.os.platform = os.platform();
|
||||
|
||||
// -------------------------------
|
||||
// OS Info from /etc/os-release (Linux)
|
||||
// -------------------------------
|
||||
const osReleasePath = '/etc/os-release';
|
||||
|
||||
if (fs.existsSync(osReleasePath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(osReleasePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^([A-Z_]+)=(.*)$/);
|
||||
if (!match) continue;
|
||||
|
||||
const key = match[1];
|
||||
const value = match[2].replace(/^"/, '').replace(/"$/, '');
|
||||
|
||||
switch (key) {
|
||||
case 'ID':
|
||||
config.host.os.id = value;
|
||||
break;
|
||||
case 'NAME':
|
||||
config.host.os.name = value;
|
||||
break;
|
||||
case 'VERSION_ID':
|
||||
config.host.os.version = value;
|
||||
break;
|
||||
case 'PRETTY_NAME':
|
||||
config.host.os.pretty_name = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Non-fatal: OS info optional
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------
|
||||
// Identity
|
||||
// -------------------------------
|
||||
|
||||
try {
|
||||
// console.log(JSON.stringify(config.dirs, null, 2));
|
||||
const idinfo = load_or_create_id(config.dirs.home);
|
||||
config.identity.id = idinfo.id;
|
||||
config.identity.created_at = idinfo.created_at;
|
||||
config.identity.hostname = config.host.hostname;
|
||||
} catch (err) {
|
||||
console.error('Idnetity Error:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Node.js Version
|
||||
// config.nodejs = {
|
||||
// version: process.version,
|
||||
// }
|
||||
config.nodejs = {
|
||||
version: process.version, // v22.16.0
|
||||
v8: process.versions.v8, // V8 engine version
|
||||
abi: process.versions.modules, // Node ABI
|
||||
arch: process.arch, // x64, arm64
|
||||
platform: process.platform, // linux, darwin, win32
|
||||
exec_path: process.execPath, // /usr/bin/node
|
||||
openssl: process.versions.openssl || null,
|
||||
uv: process.versions.uv || null,
|
||||
icu: process.versions.icu || null
|
||||
};
|
||||
|
||||
// console.log('config: ', JSON.stringify(config.dirs, null, 2));
|
||||
// console.log('modconf: ', config.dirs.modconf);
|
||||
|
||||
|
||||
// Load Module Config
|
||||
for (let m in config.modules) {
|
||||
try {
|
||||
config.modules[m] = JSON.parse(fs.readFileSync(path.join(config.dirs.modconf, `${m}.json`), 'utf8'));
|
||||
config.modules[m].dir = path.join(config.dirs.modules, m);
|
||||
config.modules[m].package = JSON.parse(fs.readFileSync(path.join(config.modules[m].dir, 'package.json'), 'utf8'));
|
||||
config.modules[m].main = path.join(config.modules[m].dir, config.modules[m].package.main);
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
// for each item, expand dirs with search and replace
|
||||
for (let i in config.modules[m]) {
|
||||
// console.log("DEBUG: " + i + " " + String(config.modules[m][i]))
|
||||
// If item is a path
|
||||
if (String(config.modules[m][i]).match(/\//)) {
|
||||
// console.log("DEBUG: PATH: " + i + " " + String(config.modules[m][i]))
|
||||
for (let s in search_replace) {
|
||||
switch (s) {
|
||||
case 'DIRNAME':
|
||||
// config.modules[m][i] = String(path.join(config.modules[m][i])).replace(s, __dirname)
|
||||
config.modules[m][i] = path.join(config.modules[m][i]).replace(s, __dirname)
|
||||
break
|
||||
default:
|
||||
// config.modules[m][i] = String(path.join(config.modules[m][i])).replace(s, config.dirs[search_replace[s]])
|
||||
config.modules[m][i] = path.join(config.modules[m][i]).replace(s, config.dirs[search_replace[s]])
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// console.log("DEBUG: NOT PATH: " + i + " " + String(config.modules[m][i]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
|
||||
// Logger Function
|
||||
function log(msg, cnsl=false) {
|
||||
const now = new Date();
|
||||
const ts_datetime = now.toISOString(); // "YYYY-MM-DDTHH:MM:SS.sssZ"
|
||||
const ts_date = ts_datetime.split('T')[0]; // "YYYY-MM-DD"
|
||||
|
||||
const logFile = path.join(config.dirs.logs, `${config.package.name}-${ts_date}.log`);
|
||||
|
||||
try {
|
||||
fs.appendFileSync(logFile, `${ts_datetime} : ${msg}\n`, 'utf8');
|
||||
if (cnsl) { console.log(msg); }
|
||||
} catch (err) {
|
||||
console.error(`Logger error: Could not write to ${logFile}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
function ipToInt(ip) {
|
||||
return ip.split('.').reduce((acc, oct) => (acc << 8) + Number(oct), 0) >>> 0;
|
||||
}
|
||||
|
||||
function intToIp(int) {
|
||||
return [
|
||||
(int >>> 24) & 255,
|
||||
(int >>> 16) & 255,
|
||||
(int >>> 8) & 255,
|
||||
int & 255
|
||||
].join('.');
|
||||
}
|
||||
|
||||
function computeBroadcast(address, netmask) {
|
||||
try {
|
||||
const ipInt = ipToInt(address);
|
||||
const maskInt = ipToInt(netmask);
|
||||
const networkInt = ipInt & maskInt;
|
||||
const broadcastInt = networkInt | (~maskInt >>> 0);
|
||||
return intToIp(broadcastInt);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function load_or_create_id(home) {
|
||||
// console.log('DEBUG: load_or_create_id() home: ' + home);
|
||||
const id_dir = path.join(home, 'conf');
|
||||
const id_file = path.join(id_dir, 'nwa-id.json');
|
||||
|
||||
try {
|
||||
if (fs.existsSync(id_file)) {
|
||||
const data = JSON.parse(fs.readFileSync(id_file, 'utf8'));
|
||||
if (data.id) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to read nwa-id.json:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Create new identity
|
||||
const id = {
|
||||
id: crypto.randomUUID(),
|
||||
created_at: new Date().toISOString(),
|
||||
hostname: os.hostname()
|
||||
};
|
||||
|
||||
try {
|
||||
fs.mkdirSync(id_dir, { recursive: true });
|
||||
fs.writeFileSync(id_file, JSON.stringify(id, null, 2), { mode: 0o600 });
|
||||
} catch (err) {
|
||||
console.error('Failed to write nwa-id.json:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Detect GPUs using /sys/class/drm (authoritative) + lspci (labels)
|
||||
* Non-root, Linux-only.
|
||||
*/
|
||||
export function detectGPUs() {
|
||||
const drmPath = '/sys/class/drm';
|
||||
const gpus = [];
|
||||
|
||||
if (!fs.existsSync(drmPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// --- Helper functions ---
|
||||
const read = p => {
|
||||
try { return fs.readFileSync(p, 'utf8').trim(); }
|
||||
catch { return null; }
|
||||
};
|
||||
|
||||
const readlink = p => {
|
||||
try { return fs.realpathSync(p); }
|
||||
catch { return null; }
|
||||
};
|
||||
|
||||
const exec = (cmd, args) => {
|
||||
try {
|
||||
return execFileSync(cmd, args, { encoding: 'utf8' }).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const vendorMap = {
|
||||
'0x10de': 'nvidia',
|
||||
'0x8086': 'intel',
|
||||
'0x1002': 'amd'
|
||||
};
|
||||
|
||||
// --- Collect render nodes indexed by PCI bus ---
|
||||
const renderNodes = {};
|
||||
for (const entry of fs.readdirSync(drmPath)) {
|
||||
if (!entry.startsWith('renderD')) continue;
|
||||
|
||||
const devPath = readlink(path.join(drmPath, entry, 'device'));
|
||||
if (!devPath) continue;
|
||||
|
||||
const pciBusId = path.basename(devPath);
|
||||
renderNodes[pciBusId] = entry;
|
||||
}
|
||||
|
||||
// --- Enumerate card devices ---
|
||||
for (const entry of fs.readdirSync(drmPath)) {
|
||||
if (!/^card[0-9]+$/.test(entry)) continue;
|
||||
|
||||
const cardPath = path.join(drmPath, entry);
|
||||
const devicePath = readlink(path.join(cardPath, 'device'));
|
||||
if (!devicePath) continue;
|
||||
|
||||
const pciBusId = path.basename(devicePath);
|
||||
|
||||
const vendorHex = read(path.join(cardPath, 'device', 'vendor'));
|
||||
const vendor = vendorMap[vendorHex] || 'unknown';
|
||||
|
||||
const driver = (() => {
|
||||
const d = readlink(path.join(cardPath, 'device', 'driver'));
|
||||
return d ? path.basename(d) : null;
|
||||
})();
|
||||
|
||||
// Human-readable model (best-effort)
|
||||
const lspciOut = exec('lspci', ['-s', pciBusId]);
|
||||
// const model = lspciOut
|
||||
// ? lspciOut.replace(/^.*?:\s*/, '')
|
||||
// : null;
|
||||
const model = (() => {
|
||||
if (!lspciOut) return null;
|
||||
|
||||
// Remove leading PCI address (e.g. "04:00.0 ")
|
||||
let s = lspciOut.replace(/^[0-9a-fA-F:.]+\s+/, '');
|
||||
|
||||
// Remove device class prefix
|
||||
s = s.replace(/^(VGA compatible controller|3D controller):\s*/i, '');
|
||||
|
||||
// Remove revision suffix
|
||||
s = s.replace(/\s*\(rev.*\)$/i, '');
|
||||
|
||||
return s.trim();
|
||||
})();
|
||||
|
||||
const renderNode = renderNodes[pciBusId] || null;
|
||||
const computeCapable = Boolean(renderNode);
|
||||
|
||||
const gpu = {
|
||||
id: entry,
|
||||
pci_bus_id: pciBusId,
|
||||
vendor,
|
||||
driver,
|
||||
model,
|
||||
compute_capable: computeCapable,
|
||||
render_node: renderNode,
|
||||
memory: {
|
||||
type: vendor === 'nvidia' ? 'dedicated' : 'shared',
|
||||
total_mb: null
|
||||
}
|
||||
};
|
||||
|
||||
// --- NVIDIA enrichment (non-root, correct fields) ---
|
||||
if (vendor === 'nvidia') {
|
||||
const nvidiaSmi = findNvidiaSmi();
|
||||
|
||||
if (nvidiaSmi) {
|
||||
const smi = exec(nvidiaSmi, [
|
||||
'--query-gpu=pci.bus_id,memory.total',
|
||||
'--format=csv,noheader,nounits'
|
||||
]);
|
||||
|
||||
if (smi) {
|
||||
const target = pciShortId(pciBusId);
|
||||
|
||||
for (const line of smi.split('\n')) {
|
||||
const cols = line.split(',').map(s => s.trim());
|
||||
if (cols.length < 2) continue;
|
||||
|
||||
const [bus, memMb] = cols;
|
||||
|
||||
if (pciShortId(bus) === target) {
|
||||
gpu.memory.total_mb = Number(memMb);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gpus.push(gpu);
|
||||
}
|
||||
|
||||
return gpus;
|
||||
}
|
||||
|
||||
// Normalize PCI bus IDs so "0000:04:00.0" === "00000000:04:00.0"
|
||||
function normalizePciBusId(id) {
|
||||
return id
|
||||
.toLowerCase()
|
||||
.replace(/^0+/, '') // strip leading domain zeros
|
||||
.replace(/^:/, ''); // safety
|
||||
}
|
||||
|
||||
function findNvidiaSmi() {
|
||||
const candidates = [
|
||||
'/usr/bin/nvidia-smi',
|
||||
'/bin/nvidia-smi',
|
||||
'/usr/local/bin/nvidia-smi'
|
||||
];
|
||||
|
||||
for (const p of candidates) {
|
||||
try {
|
||||
fs.accessSync(p, fs.constants.X_OK);
|
||||
return p;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function pciShortId(id) {
|
||||
if (!id) return null;
|
||||
|
||||
// Extract the stable "bus:device.function" portion
|
||||
const m = id.match(/([0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9])/);
|
||||
return m ? m[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
function getProcessStartTs() {
|
||||
return PROCESS_START_TS;
|
||||
}
|
||||
|
||||
function getUptimeMs() {
|
||||
return Date.now() - PROCESS_START_TS;
|
||||
}
|
||||
|
||||
function getUptimeSec() {
|
||||
return Math.floor((Date.now() - PROCESS_START_TS) / 1000);
|
||||
}
|
||||
|
||||
function getUptimeDHMS() {
|
||||
const uptimeSec = getUptimeSec();
|
||||
const d = Math.floor(uptimeSec / 86400);
|
||||
const h = Math.floor((uptimeSec % 86400) / 3600);
|
||||
const m = Math.floor((uptimeSec % 3600) / 60);
|
||||
const s = Math.floor(uptimeSec % 60);
|
||||
return `${d}d ${h}h ${m}m ${s}s`;
|
||||
}
|
||||
|
||||
|
||||
export { load_config, log, ipToInt, intToIp, computeBroadcast, getProcessStartTs, getUptimeMs, getUptimeSec, getUptimeDHMS };
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"main": "index.js",
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
const module = 'TEMPLATE'; // Module Name
|
||||
import { load_config, log } from '../nwa-lib/index.js';
|
||||
const config = load_config();
|
||||
|
||||
log(`Loaded module: ${module}`);
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"main": "index.js",
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
import serveIndex from 'serve-index';
|
||||
import open from 'open';
|
||||
const module = 'web'; // Module Name
|
||||
import { load_config, log } from '../nwa-lib/index.js';
|
||||
const config = load_config();
|
||||
|
||||
log(`${module}: Loaded module: ${module}`);
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { parse } from 'csv-parse';
|
||||
import express from 'express';
|
||||
const app = express();
|
||||
|
||||
|
||||
// Serve config as JSON
|
||||
app.get('/api/config', (req, res) => {
|
||||
res.json(config);
|
||||
|
||||
let webreqmeta = {
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent')
|
||||
};
|
||||
|
||||
log(module + ': WEBAPI: ' + JSON.stringify(webreqmeta))
|
||||
});
|
||||
|
||||
// Serve sidepanel nav as JSON
|
||||
app.get('/api/nav/sidepanel', (req, res) => {
|
||||
const sideMenuPath = path.join(
|
||||
config.dirs.html,
|
||||
'conf',
|
||||
'nav-sidepanel.json'
|
||||
);
|
||||
|
||||
try {
|
||||
const sideMenuData = JSON.parse(
|
||||
fs.readFileSync(sideMenuPath, 'utf8')
|
||||
);
|
||||
res.json(sideMenuData);
|
||||
|
||||
log(module + ': INFO: GET /api/nav/sidepanel');
|
||||
} catch (err) {
|
||||
log(`ERROR: GET /api/nav/sidepanel failed: ${err}`);
|
||||
res.status(500).json({ error: 'Failed to load nav-sidepanel.json' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Serve nav as JSON
|
||||
app.get('/api/nav', (req, res) => {
|
||||
const menuPath = (path.join(config.dirs.html, 'conf', 'nav.json'));
|
||||
|
||||
try {
|
||||
const menuData = JSON.parse(fs.readFileSync(menuPath, 'utf8'));
|
||||
res.json(menuData);
|
||||
|
||||
log(module + ': INFO: GET /api/nav');
|
||||
} catch (err) {
|
||||
log(`ERROR: GET /api/nav failed: ${err}`);
|
||||
res.status(500).json({ error: 'Failed to load nav.json' });
|
||||
}
|
||||
});
|
||||
|
||||
// API
|
||||
app.get ('/api', (req, res) => {
|
||||
res.json(gen_api_usage(req));
|
||||
|
||||
let webreqmeta = {
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent')
|
||||
};
|
||||
|
||||
log(module + ': WEBAPI: ' + JSON.stringify(webreqmeta))
|
||||
});
|
||||
|
||||
|
||||
|
||||
// /img
|
||||
app.use('/img', logStaticRequests('img'), express.static(path.join(config.dirs.html, 'img')));
|
||||
|
||||
// /css
|
||||
app.use('/css', logStaticRequests('css'), express.static(path.join(config.dirs.html, 'css')));
|
||||
|
||||
// /js
|
||||
app.use('/js', logStaticRequests('js'), express.static(path.join(config.dirs.html, 'js')));
|
||||
|
||||
|
||||
// md
|
||||
app.use('/md', logStaticRequests('md'), express.static(path.join(config.dirs.html, 'md')));
|
||||
|
||||
|
||||
// Web Libs
|
||||
for (let wl in config.modules.web.weblibs) {
|
||||
log(`${module}: Serving WebLib: /${wl} at ${path.join(config.dirs.app, 'node_modules', config.modules.web.weblibs[wl])}`)
|
||||
app.use('/' + wl, logStaticRequests(wl), express.static(path.join(config.dirs.app, 'node_modules', config.modules.web.weblibs[wl])));
|
||||
}
|
||||
|
||||
|
||||
// Top Level Website
|
||||
app.use('/', logStaticRequests('html'), express.static(path.join(config.dirs.html)));
|
||||
|
||||
// Catch-all for 404 Not Found
|
||||
app.use((req, res, next) => {
|
||||
const webreqmeta = {
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent')
|
||||
};
|
||||
|
||||
log(`404 NOT FOUND: ${JSON.stringify(webreqmeta)}`);
|
||||
|
||||
if (req.originalUrl.startsWith('/api/')) {
|
||||
res.status(404).json({ error: 'Not Found', path: req.originalUrl });
|
||||
} else {
|
||||
res.status(404).sendFile(path.join(config.dirs.html, '404.html'));
|
||||
}
|
||||
});
|
||||
|
||||
// Starting Web Server
|
||||
const bindAddrs =
|
||||
Array.isArray(config.modules[module].bind_ip_addrs) &&
|
||||
config.modules[module].bind_ip_addrs.length > 0
|
||||
? config.modules[module].bind_ip_addrs
|
||||
: ['0.0.0.0'];
|
||||
|
||||
for (const ip_addr of bindAddrs) {
|
||||
if (ip_addr === '0.0.0.0') {
|
||||
const server = app.listen(config.modules[module].port, '0.0.0.0', () => {
|
||||
for (const ip of config.host.ips) {
|
||||
log(`INFO: WebUI running at http://${ip}:${config.modules[module].port}`, true);
|
||||
|
||||
if (config.modules[module].open_browser && ip === '127.0.0.1') {
|
||||
open(`http://127.0.0.1:${config.modules[module].port}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
log(`ERROR: WebUI failed to start on 0.0.0.0:${config.modules[module].port}: ${err}`, true);
|
||||
});
|
||||
} else {
|
||||
const server = app.listen(config.modules[module].port, ip_addr, () => {
|
||||
log(`INFO: WebUI running at http://${ip_addr}:${config.modules[module].port}`, true);
|
||||
if (config.modules[module].open_browser) {
|
||||
open(`http://${ip_addr}:${config.modules[module].port}`);
|
||||
}
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
log(`ERROR: WebUI failed to start on ${ip_addr}:${config.modules[module].port}: ${err}`, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// ----------- Libraries -------------
|
||||
function logStaticRequests(subdir) {
|
||||
const func = 'logStaticRequests';
|
||||
return (req, res, next) => {
|
||||
const webreqmeta = {
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent'),
|
||||
type: subdir
|
||||
};
|
||||
log(`${module}: ${func}: Web Req: ${JSON.stringify(webreqmeta)}`);
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// API Usage
|
||||
function gen_api_usage(req) {
|
||||
// let base_url = "http://localhost:" + config.web.port;
|
||||
const base_url = `${req.protocol}://${req.headers.host}`;
|
||||
let usage = {};
|
||||
|
||||
usage['API'] = {}
|
||||
usage['API']['DESC'] = "API"
|
||||
usage['API']['URL'] = base_url + '/api';
|
||||
|
||||
usage['API']['CONFIG'] = {}
|
||||
usage['API']['CONFIG']['DESC'] = "API Config JSON"
|
||||
usage['API']['CONFIG']['URL'] = base_url + '/api/config';
|
||||
|
||||
return usage;
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"main": "index.js",
|
||||
"type": "module"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"name": "nwa",
|
||||
"version": "0.1.0",
|
||||
"version_date": "2026-02-08",
|
||||
"description": "Node Web App (NWA)",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/jehaverlack/nwa.git"
|
||||
},
|
||||
"author": "John Haverlack",
|
||||
"copyright": "2026 John Haverlack",
|
||||
"license": "MIT",
|
||||
"license_url": "https://opensource.org/license/MIT",
|
||||
"bugs": {
|
||||
"url": ""
|
||||
},
|
||||
"homepage": "https://github.com/jehaverlack/nwa",
|
||||
"dependencies": {
|
||||
"@alenaksu/json-viewer": "^2.0.1",
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"aedes": "^0.51.3",
|
||||
"bootstrap": "^5.3.7",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"chart.js": "^4.5.1",
|
||||
"csv-parse": "^5.6.0",
|
||||
"d3": "^7.9.0",
|
||||
"express": "^5.1.0",
|
||||
"graph.js": "^1.21.1",
|
||||
"highlight.js": "^11.9.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"marked": "^15.0.11",
|
||||
"mathjax-full": "^3.2.1",
|
||||
"mqtt": "^5.14.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"open": "^11.0.0",
|
||||
"serve-index": "^1.9.1",
|
||||
"three": "^0.181.2",
|
||||
"websocket-stream": "^5.5.2",
|
||||
"ws": "^8.18.3",
|
||||
"yargs": "^18.0.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"enabled": true
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"enabled": true,
|
||||
"bind_ip_addrs": ["0.0.0.0"],
|
||||
"port": 8080,
|
||||
"open_browser": false,
|
||||
"weblibs": {
|
||||
"bootstrap":"bootstrap/dist",
|
||||
"bootstrap-icons":"bootstrap-icons",
|
||||
"chartjs":"chart.js/dist",
|
||||
"d3":"d3/dist",
|
||||
"fontawesome":"@fortawesome/fontawesome-free",
|
||||
"graphjs":"graph.js/dist",
|
||||
"html-to-image":"html-to-image/dist",
|
||||
"json-viewer":"@alenaksu/json-viewer/dist",
|
||||
"marked":"marked",
|
||||
"mathjax":"mathjax-full/es5",
|
||||
"mqttjs":"mqtt/dist",
|
||||
"three/examples/jsm":"three/examples/jsm",
|
||||
"three":"three"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"cluster": {
|
||||
"id": "haverlab",
|
||||
"name": "Haverlab",
|
||||
"desc": "John Haverlack's Home Lab"
|
||||
},
|
||||
"dirs": {
|
||||
"base": "DIRNAME/../..",
|
||||
"app": "BASE/app",
|
||||
"modules": "APP/modules",
|
||||
"logs": "HOME/logs",
|
||||
"data": "HOME/data",
|
||||
"clstr": "DATA/cluster",
|
||||
"proj": "DATA/proj",
|
||||
"jobs": "DATA/jobs",
|
||||
"conf": "HOME/conf",
|
||||
"modconf": "BASE/conf/modules",
|
||||
"html": "BASE/html"
|
||||
},
|
||||
"modules": {
|
||||
"web": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"id": "1a381c70-9c9d-41d6-b3f3-41b2b2dcb201",
|
||||
"created_at": "2026-06-28T16:46:10.204Z",
|
||||
"hostname": "jehaverlack-ws-0"
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>404 - Not Found</title>
|
||||
<link rel="stylesheet" href="/css/nwa.css" />
|
||||
</head>
|
||||
<body>
|
||||
<h1>404 - Page Not Found</h1>
|
||||
<p>The requested resource could not be found on this server.</p>
|
||||
<a href="/">Return to Home</a>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<!-- <header class="custom-header py-2 border-bottom">
|
||||
<div class="container d-flex align-items-center justify-content-between flex-wrap"> -->
|
||||
|
||||
<!-- Title -->
|
||||
<!-- <h1 class="h5 text-white text-end mt-2 mt-sm-0">
|
||||
<span id="banner-title"></span>
|
||||
</h1>
|
||||
</div>
|
||||
</header> -->
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
{
|
||||
"sdl":{
|
||||
"title": "SDL",
|
||||
"desc": "Software Defined Laboratory",
|
||||
"icon": "fa-solid fa-square-binary",
|
||||
"page": "index",
|
||||
"enabled": false,
|
||||
"items": []
|
||||
},
|
||||
"dash": {
|
||||
"title": "Dash",
|
||||
"desc": "Dashboard",
|
||||
"icon": "fa-solid fa-gauge",
|
||||
"page": "dash",
|
||||
"enabled": false,
|
||||
"items": []
|
||||
},
|
||||
"wkrs": {
|
||||
"title": "Workers",
|
||||
"desc": "Workers",
|
||||
"icon": "fa-solid fa-server",
|
||||
"page": "wkrs",
|
||||
"enabled": false,
|
||||
"items": []
|
||||
},
|
||||
"proj": {
|
||||
"title": "Projects",
|
||||
"desc": "Projects",
|
||||
"icon": "fa-solid fa-layer-group",
|
||||
"page": "proj",
|
||||
"enabled": true,
|
||||
"items": [
|
||||
{
|
||||
"title": "Experiments",
|
||||
"desc": "Experiments",
|
||||
"icon": "fa-solid fa-flask",
|
||||
"page": "expr",
|
||||
"enabled": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"jobs": {
|
||||
"title": "Jobs",
|
||||
"desc": "Jobs",
|
||||
"icon": "fa-solid fa-person-digging",
|
||||
"page": "jobs",
|
||||
"enabled": false,
|
||||
"items": []
|
||||
},
|
||||
"app": {
|
||||
"title": "App",
|
||||
"icon": "fa-solid fa-dice-d20",
|
||||
"enabled": false,
|
||||
"items": [
|
||||
{
|
||||
"title": "About",
|
||||
"icon": "fa-solid fa-circle-info",
|
||||
"page": "about",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"title": "API",
|
||||
"icon": "fa-solid fa-code",
|
||||
"page": "api",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"title": "Docs",
|
||||
"icon": "fa-solid fa-book",
|
||||
"page": "docs",
|
||||
"enabled": false
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"experiments": {
|
||||
"title": "Experiments",
|
||||
"icon": "fa-solid fa-flask",
|
||||
"enabled": false,
|
||||
"items": [
|
||||
{
|
||||
"title": "Browse",
|
||||
"icon": "fa-solid fa-table-list",
|
||||
"page": "experiments",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"title": "New Experiment",
|
||||
"icon": "fa-solid fa-plus",
|
||||
"page": "experiment-new",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"runs": {
|
||||
"title": "Runs",
|
||||
"icon": "fa-solid fa-play",
|
||||
"enabled": false,
|
||||
"items": [
|
||||
{
|
||||
"title": "Active Runs",
|
||||
"icon": "fa-solid fa-spinner",
|
||||
"page": "runs-active",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"title": "History",
|
||||
"icon": "fa-solid fa-clock-rotate-left",
|
||||
"page": "runs-history",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
[
|
||||
{
|
||||
"title": "Node Web App (NWA)",
|
||||
"icon": "fa-solid fa-square-binary",
|
||||
"page": "index",
|
||||
"enabled": true,
|
||||
"brand": true,
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"title": "App",
|
||||
"icon": "fa-solid fa-dice-d20",
|
||||
"page": "app",
|
||||
"enabled": true,
|
||||
"children": [
|
||||
{
|
||||
"title": "About",
|
||||
"icon": "fa-solid fa-circle-info",
|
||||
"page": "about",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"title": "Change Log",
|
||||
"icon": "fa-solid fa-circle-info",
|
||||
"page": "changelog",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"title": "Docs",
|
||||
"icon": "fa-solid fa-book",
|
||||
"page": "docs",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"title": "Settings",
|
||||
"icon": "fa-solid fa-gear",
|
||||
"page": "settings",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"title": "API",
|
||||
"icon": "fa-solid fa-code",
|
||||
"page": "api",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
|
|
@ -0,0 +1,307 @@
|
|||
/* ------------------------------
|
||||
Callouts (final tuned spacing)
|
||||
------------------------------ */
|
||||
|
||||
.callout-established,
|
||||
.callout-proposed,
|
||||
.callout-speculative,
|
||||
.callout-caution,
|
||||
.callout-warning,
|
||||
.callout-danger {
|
||||
border-radius: 6px;
|
||||
padding: 5px; /* internal padding */
|
||||
margin-top: 5px; /* external spacing tightened */
|
||||
margin-bottom: 5px;
|
||||
border-left: 6px solid;
|
||||
background: #fefefe;
|
||||
}
|
||||
|
||||
/* Title line */
|
||||
.callout-established p:first-child strong,
|
||||
.callout-proposed p:first-child strong,
|
||||
.callout-speculative p:first-child strong,
|
||||
.callout-caution p:first-child strong,
|
||||
.callout-warning p:first-child strong,
|
||||
.callout-danger p:first-child strong {
|
||||
display: block;
|
||||
font-family: "Libertinus Sans", sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
/* Paragraphs inside callouts */
|
||||
.callout-established p,
|
||||
.callout-proposed p,
|
||||
.callout-speculative p,
|
||||
.callout-caution p,
|
||||
.callout-warning p,
|
||||
.callout-danger p {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
line-height: 1.45; /* slightly condensed text spacing */
|
||||
}
|
||||
|
||||
/* Remove top margin on first paragraph, bottom on last */
|
||||
.callout-established p:first-child,
|
||||
.callout-proposed p:first-child,
|
||||
.callout-speculative p:first-child,
|
||||
.callout-caution p:first-child,
|
||||
.callout-warning p:first-child,
|
||||
.callout-danger p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.callout-established p:last-child,
|
||||
.callout-proposed p:last-child,
|
||||
.callout-speculative p:last-child,
|
||||
.callout-caution p:last-child,
|
||||
.callout-warning p:last-child,
|
||||
.callout-danger p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Math inside callouts */
|
||||
.callout-established .math.display,
|
||||
.callout-proposed .math.display,
|
||||
.callout-speculative .math.display,
|
||||
.callout-caution .math.display,
|
||||
.callout-warning .math.display,
|
||||
.callout-danger .math.display {
|
||||
margin: 0.4em 0;
|
||||
}
|
||||
|
||||
/* Colors */
|
||||
.callout-established {
|
||||
background: #e8f7e8;
|
||||
border-left-color: #3a8d3a;
|
||||
}
|
||||
.callout-proposed {
|
||||
background: #e8f0ff;
|
||||
border-left-color: #3b6dd8;
|
||||
}
|
||||
.callout-speculative {
|
||||
background: #f7e8ff;
|
||||
border-left-color: #8b2be2;
|
||||
}
|
||||
|
||||
.callout-caution {
|
||||
background: #fff0e8;
|
||||
border-left-color: #f5a22f;
|
||||
}
|
||||
|
||||
.callout-warning {
|
||||
background: #fff0e8;
|
||||
border-left-color: #f5a22f;
|
||||
}
|
||||
|
||||
.callout-danger {
|
||||
background: #ffe8e8;
|
||||
border-left-color: #d83b3a;
|
||||
}
|
||||
|
||||
|
||||
/* three.js */
|
||||
|
||||
#proto-time-viz {
|
||||
width: 100%;
|
||||
height: 600px; /* or auto-resize later */
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
/* #cylindrical-time-viz {
|
||||
width: 100%;
|
||||
height: 600px; /* or auto-resize later */
|
||||
/* border: 1px solid #333; */
|
||||
/* } */
|
||||
|
||||
|
||||
json-viewer {
|
||||
background: #ffffff;
|
||||
color: #222;
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid #ddd;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* Keys */
|
||||
json-viewer .property {
|
||||
color: #005cc5;
|
||||
}
|
||||
|
||||
/* Strings */
|
||||
json-viewer .string {
|
||||
color: #22863a;
|
||||
}
|
||||
|
||||
/* Numbers */
|
||||
json-viewer .number {
|
||||
color: #b31d28;
|
||||
}
|
||||
|
||||
/* Booleans */
|
||||
json-viewer .boolean {
|
||||
color: #6f42c1;
|
||||
}
|
||||
|
||||
/* Null */
|
||||
json-viewer .null {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* Expand/collapse arrows */
|
||||
json-viewer .toggle {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Hover */
|
||||
json-viewer .property:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
|
||||
#ssl-graph {
|
||||
border: 1px solid #ccc;
|
||||
background-color: #ffffff;
|
||||
border-radius: 4px;
|
||||
/* padding: 6px;
|
||||
box-sizing: border-box; */
|
||||
}
|
||||
|
||||
|
||||
/* Side panel */
|
||||
#sidepanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 200px;
|
||||
transition: width 0.2s ease;
|
||||
border-right: 1px solid #ddd;
|
||||
}
|
||||
|
||||
#sidepanel.collapsed {
|
||||
width: 47px;
|
||||
}
|
||||
|
||||
#sidepanel-rail {
|
||||
display: flex;
|
||||
justify-content: flex-left;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
#sidepanel-inner {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#sidepanel.collapsed #sidepanel-inner {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#sidepanel-toggle {
|
||||
/* margin-right: 0.25rem; */
|
||||
border-style: none;
|
||||
background-color: transparent;
|
||||
color: hwb(0 55% 45%);
|
||||
}
|
||||
|
||||
#sidepanel .nav-link {
|
||||
color: lab(32.07% 0.83 0.29);
|
||||
}
|
||||
|
||||
#sidepanel i {
|
||||
color: hwb(0 55% 45%);
|
||||
}
|
||||
|
||||
/* .dash-val {
|
||||
color: hwb(116 7% 35%);
|
||||
} */
|
||||
|
||||
[data-bs-theme="light"] .dash-val {
|
||||
color: hwb(116 0% 46%);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .dash-val {
|
||||
color: hwb(116 40% 0%); /* 40% lighter for dark backgrounds */
|
||||
}
|
||||
|
||||
/* Dark Theme */
|
||||
|
||||
/* Dark mode for callouts */
|
||||
[data-bs-theme="dark"] .callout-established,
|
||||
[data-bs-theme="dark"] .callout-proposed,
|
||||
[data-bs-theme="dark"] .callout-speculative,
|
||||
[data-bs-theme="dark"] .callout-caution,
|
||||
[data-bs-theme="dark"] .callout-warning,
|
||||
[data-bs-theme="dark"] .callout-danger {
|
||||
background: #1a1a1a;
|
||||
color: #e9ecef;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .callout-established {
|
||||
background: #1a3a1a;
|
||||
border-left-color: #5cb85c;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .callout-proposed {
|
||||
background: #1a2a3a;
|
||||
border-left-color: #5b8fd8;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .callout-speculative {
|
||||
background: #2a1a3a;
|
||||
border-left-color: #ab5bea;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .callout-caution,
|
||||
[data-bs-theme="dark"] .callout-warning {
|
||||
background: #3a2a1a;
|
||||
border-left-color: #f5a22f;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .callout-danger {
|
||||
background: #3a1a1a;
|
||||
border-left-color: #d83b3a;
|
||||
}
|
||||
|
||||
/* Dark mode for side panel */
|
||||
[data-bs-theme="dark"] #sidepanel {
|
||||
border-right-color: #495057;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] #sidepanel-rail {
|
||||
border-bottom-color: #495057;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] #sidepanel .nav-link {
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
/* Dark mode for json-viewer */
|
||||
[data-bs-theme="dark"] json-viewer {
|
||||
background: #212529;
|
||||
color: #e9ecef;
|
||||
border-color: #495057;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] json-viewer .property {
|
||||
color: #58a6ff;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] json-viewer .string {
|
||||
color: #7ee787;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] json-viewer .number {
|
||||
color: #ff7b72;
|
||||
}
|
||||
|
||||
/* Dark mode for tables */
|
||||
[data-bs-theme="dark"] th {
|
||||
background: #343a40;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] th,
|
||||
[data-bs-theme="dark"] td {
|
||||
border-color: #495057;
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
body {
|
||||
font-family: sans-serif;
|
||||
/* margin: 2rem; */
|
||||
}
|
||||
|
||||
header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #ccc;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
#page {
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.custom-header {
|
||||
background-color: #003764;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
footer {
|
||||
--bs-secondary-color: #666;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 231 KiB |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M418.4 157.9c35.3-8.3 61.6-40 61.6-77.9c0-44.2-35.8-80-80-80c-43.4 0-78.7 34.5-80 77.5L136.2 151.1C121.7 136.8 101.9 128 80 128c-44.2 0-80 35.8-80 80s35.8 80 80 80c12.2 0 23.8-2.7 34.1-7.6L259.7 407.8c-2.4 7.6-3.7 15.8-3.7 24.2c0 44.2 35.8 80 80 80s80-35.8 80-80c0-27.7-14-52.1-35.4-66.4l37.8-207.7zM156.3 232.2c2.2-6.9 3.5-14.2 3.7-21.7l183.8-73.5c3.6 3.5 7.4 6.7 11.6 9.5L317.6 354.1c-5.5 1.3-10.8 3.1-15.8 5.5L156.3 232.2z"/></svg>
|
||||
|
After Width: | Height: | Size: 656 B |
Binary file not shown.
|
After Width: | Height: | Size: 231 KiB |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M0 96C0 60.7 28.7 32 64 32l320 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zm144 4c-24.3 0-44 19.7-44 44l0 48c0 24.3 19.7 44 44 44l32 0c24.3 0 44-19.7 44-44l0-48c0-24.3-19.7-44-44-44l-32 0zm-4 44c0-2.2 1.8-4 4-4l32 0c2.2 0 4 1.8 4 4l0 48c0 2.2-1.8 4-4 4l-32 0c-2.2 0-4-1.8-4-4l0-48zm140-44c-11 0-20 9-20 20c0 9.7 6.9 17.7 16 19.6l0 76.4c0 11 9 20 20 20s20-9 20-20l0-96c0-11-9-20-20-20l-16 0zM132 296c0 9.7 6.9 17.7 16 19.6l0 76.4c0 11 9 20 20 20s20-9 20-20l0-96c0-11-9-20-20-20l-16 0c-11 0-20 9-20 20zm96 24l0 48c0 24.3 19.7 44 44 44l32 0c24.3 0 44-19.7 44-44l0-48c0-24.3-19.7-44-44-44l-32 0c-24.3 0-44 19.7-44 44zm44-4l32 0c2.2 0 4 1.8 4 4l0 48c0 2.2-1.8 4-4 4l-32 0c-2.2 0-4-1.8-4-4l0-48c0-2.2 1.8-4 4-4z"/></svg>
|
||||
|
After Width: | Height: | Size: 976 B |
|
|
@ -0,0 +1,92 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title></title>
|
||||
|
||||
<link rel="icon" href="/img/favicon.ico">
|
||||
|
||||
<!-- Bootstrap CSS -->
|
||||
<link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css">
|
||||
|
||||
<!-- Bootstrap Icons -->
|
||||
<link rel="stylesheet" href="/bootstrap-icons/font/bootstrap-icons.min.css">
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="/fontawesome/css/all.min.css">
|
||||
|
||||
<script src="/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<script src="/marked/marked.min.js" defer></script>
|
||||
|
||||
<link rel="stylesheet" href="/css/nwa.css" />
|
||||
<link rel="stylesheet" href="/css/custom-styles.css" />
|
||||
|
||||
<script>
|
||||
window.MathJax = {
|
||||
tex: {
|
||||
inlineMath: [["$", "$"], ["\\(", "\\)"]],
|
||||
displayMath: [["$$", "$$"], ["\\[", "\\]"]],
|
||||
},
|
||||
svg: {
|
||||
fontCache: "global"
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<script src="/mathjax/tex-svg.js" id="MathJax-script" async></script>
|
||||
|
||||
<script src="/chartjs/chart.umd.min.js" defer></script>
|
||||
|
||||
<script src="/d3/d3.min.js" defer></script>
|
||||
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "/three/build/three.module.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script src="/graphjs/graph.full.js"></script>
|
||||
|
||||
<script src="/json-viewer/json-viewer.bundle.js"></script>
|
||||
|
||||
<script src="/mqttjs/mqtt.min.js"></script>
|
||||
|
||||
<script src="/html-to-image/html-to-image.js"></script>
|
||||
|
||||
<script type="module" src="/js/weblib.js"></script>
|
||||
</head>
|
||||
<body style="overflow-x: hidden;">
|
||||
<div id="banner"></div>
|
||||
|
||||
<div id="nav"></div>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row d-flex flex-nowrap">
|
||||
|
||||
<aside id="sidepanel" class="">
|
||||
<!-- Collapse / expand rail -->
|
||||
<div id="sidepanel-rail">
|
||||
<button
|
||||
id="sidepanel-toggle"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
title="Toggle side panel">
|
||||
<i class="fa-solid fa-folder-minus"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic content goes here -->
|
||||
<div id="sidepanel-inner"></div>
|
||||
</aside>
|
||||
|
||||
<main id="page" class="flex-grow-1"></main>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="footer"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
fetch('/api/config')
|
||||
.then(res => res.json())
|
||||
.then(config => {
|
||||
|
||||
// Package Vars
|
||||
for (let pkvar in config.package) {
|
||||
let appvar = 'app_' + pkvar;
|
||||
|
||||
// Select *all* elements with this class
|
||||
const elements = document.querySelectorAll('.' + appvar);
|
||||
// console.log(`Found ${elements.length} elements with .${appvar}`);
|
||||
|
||||
elements.forEach(el => {
|
||||
// console.log(`Setting .${appvar} = ${config.package[pkvar]}`);
|
||||
|
||||
switch (pkvar) {
|
||||
case 'license': {
|
||||
const lic = config.package.license;
|
||||
const licUrl = config.package.license_url;
|
||||
|
||||
if (licUrl) {
|
||||
el.innerHTML = `<a target="_blank" href="${licUrl}">${lic}</a>`;
|
||||
} else {
|
||||
el.textContent = lic;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'homepage':
|
||||
let home_html = `<a target="_blank" href="${config.package[pkvar]}">${config.package[pkvar]}</a>`;
|
||||
el.innerHTML = home_html;
|
||||
break;
|
||||
default:
|
||||
el.innerHTML = config.package[pkvar];
|
||||
break;
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
// Node.js
|
||||
let node_html = '';
|
||||
node_html += '<ul class="">'
|
||||
node_html += ` <li><b>Version:</b> ${config.nodejs.version}</li>`
|
||||
node_html += ` <li><b>Arch:</b> ${config.nodejs.arch}</li>`
|
||||
node_html += ` <li><b>Platform:</b> ${config.nodejs.platform}</li>`
|
||||
node_html += '</ul>';
|
||||
|
||||
document.getElementById('nodejs').innerHTML = node_html;
|
||||
|
||||
// Dependencies
|
||||
let dep_keys = ['name', 'version', 'license', 'homepage'];
|
||||
let dep_html = '';
|
||||
|
||||
dep_html += '<div class="row g-3">'; // g-3 adds spacing between cards
|
||||
|
||||
for (let dep in config.dependencies) {
|
||||
const depInfo = config.dependencies[dep];
|
||||
console.log('Dep: ' + dep);
|
||||
|
||||
dep_html += `
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<div class="card shadow-sm border-0 h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-primary">${depInfo.name || dep}</h5>
|
||||
<ul class="list-unstyled small mb-0">
|
||||
`;
|
||||
|
||||
for (let key of dep_keys) {
|
||||
if (!depInfo[key]) continue;
|
||||
if (key === 'homepage') {
|
||||
dep_html += `
|
||||
<li><b>${key}:</b>
|
||||
<a href="${depInfo[key]}" target="_blank" class="link-primary text-decoration-none">
|
||||
${depInfo[key]}
|
||||
</a>
|
||||
</li>
|
||||
`;
|
||||
} else {
|
||||
dep_html += `<li><b>${key}:</b> ${depInfo[key]}</li>`;
|
||||
}
|
||||
}
|
||||
|
||||
dep_html += `
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
dep_html += '</div>';
|
||||
|
||||
document.getElementById('dependancies').innerHTML = dep_html;
|
||||
|
||||
|
||||
})
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
const host = window.location.host; // includes hostname and port
|
||||
const link = document.getElementById('api-link');
|
||||
if (link) {
|
||||
link.href = `http://${host}/api`;
|
||||
link.textContent = `http://${host}/api`;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
console.log('Settings.js loaded');
|
||||
|
||||
// Get current theme from localStorage
|
||||
const currentTheme = localStorage.getItem('theme') || 'dark';
|
||||
console.log('Current theme:', currentTheme);
|
||||
|
||||
// Set the toggle to match current theme
|
||||
const themeSwitch = document.getElementById('themeSwitch');
|
||||
const themeLabel = document.querySelector('label[for="themeSwitch"]');
|
||||
|
||||
console.log('Theme switch element found:', themeSwitch);
|
||||
|
||||
if (themeSwitch && themeLabel) {
|
||||
themeSwitch.checked = (currentTheme === 'dark');
|
||||
|
||||
// Update label based on current theme
|
||||
updateLabel(currentTheme);
|
||||
|
||||
console.log('Switch checked state:', themeSwitch.checked);
|
||||
|
||||
// Listen for changes
|
||||
themeSwitch.addEventListener('change', (e) => {
|
||||
console.log('Switch toggled!', e.target.checked);
|
||||
const newTheme = e.target.checked ? 'dark' : 'light';
|
||||
console.log('Setting new theme:', newTheme);
|
||||
setTheme(newTheme);
|
||||
updateLabel(newTheme);
|
||||
});
|
||||
} else {
|
||||
console.error('themeSwitch or themeLabel element not found!');
|
||||
}
|
||||
|
||||
function setTheme(theme) {
|
||||
console.log('setTheme called with:', theme);
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
console.log('Theme set to:', theme);
|
||||
console.log('HTML data-bs-theme attribute:', document.documentElement.getAttribute('data-bs-theme'));
|
||||
}
|
||||
|
||||
function updateLabel(theme) {
|
||||
const themeLabel = document.querySelector('label[for="themeSwitch"]');
|
||||
if (themeLabel) {
|
||||
if (theme === 'dark') {
|
||||
themeLabel.innerHTML = '<i class="fas fa-moon me-2"></i>Dark Mode';
|
||||
} else {
|
||||
themeLabel.innerHTML = '<i class="fas fa-sun me-2"></i>Light Mode';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,913 @@
|
|||
// Dynamically include fragments
|
||||
async function includeFragment(id, url) {
|
||||
const res = await fetch(url);
|
||||
const html = await res.text();
|
||||
document.getElementById(id).innerHTML = html;
|
||||
}
|
||||
|
||||
// Load a markdown file into #page
|
||||
async function loadMarkdown(page) {
|
||||
const mdPath = `/md/${page}.md`;
|
||||
|
||||
try {
|
||||
const response = await fetch(mdPath);
|
||||
const hasMarkdown = response.ok;
|
||||
const mdText = hasMarkdown ? await response.text() : "";
|
||||
|
||||
// Load nav metadata (top + sidepanel)
|
||||
const [navRes, sideNavRes] = await Promise.all([
|
||||
fetch('/api/nav'),
|
||||
fetch('/api/nav/sidepanel')
|
||||
]);
|
||||
|
||||
const navItems = await navRes.json();
|
||||
const sideNav = await sideNavRes.json();
|
||||
|
||||
// ---- Locate metadata (nav.json first, then nav-sidepanel.json) ----
|
||||
let meta = null;
|
||||
|
||||
// 1) nav.json — brand
|
||||
meta = navItems.find(item => item.brand === true && item.page === page);
|
||||
|
||||
// 2) nav.json — top-level
|
||||
if (!meta) {
|
||||
meta = navItems.find(item => item.page === page);
|
||||
}
|
||||
|
||||
// 3) nav.json — children
|
||||
if (!meta) {
|
||||
for (const item of navItems) {
|
||||
if (Array.isArray(item.children)) {
|
||||
const child = item.children.find(c => c.page === page);
|
||||
if (child) {
|
||||
meta = child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4) nav-sidepanel.json — section headers
|
||||
if (!meta) {
|
||||
for (const section of Object.values(sideNav)) {
|
||||
if (section.page === page) {
|
||||
meta = section;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5) nav-sidepanel.json — section items
|
||||
if (!meta) {
|
||||
for (const section of Object.values(sideNav)) {
|
||||
if (Array.isArray(section.items)) {
|
||||
const item = section.items.find(i => i.page === page);
|
||||
if (item) {
|
||||
meta = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
if (!meta) {
|
||||
meta = {
|
||||
title: page,
|
||||
icon: ""
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Fallback title if still not found
|
||||
if (!meta) {
|
||||
meta = {
|
||||
title: page,
|
||||
icon: ""
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Build header HTML (NOT passed to marked) ----
|
||||
const headerHtml = `
|
||||
<h1 class="page-title my-4">
|
||||
${meta.icon ? `<i class="${meta.icon} me-3"></i>` : ""}
|
||||
${meta.title}
|
||||
</h1>
|
||||
`;
|
||||
|
||||
// ---- Render header directly ----
|
||||
const pageEl = document.getElementById('page');
|
||||
pageEl.innerHTML = headerHtml;
|
||||
|
||||
// ---- Render Markdown content below header ----
|
||||
if (hasMarkdown) {
|
||||
pageEl.innerHTML += marked.parse(mdText);
|
||||
} else {
|
||||
pageEl.innerHTML += marked.parse(`
|
||||
> No markdown file found for: \`${page}.md\`
|
||||
`);
|
||||
}
|
||||
|
||||
// Load optional page-specific JS
|
||||
await loadPageScript(page);
|
||||
|
||||
// Re-render MathJax
|
||||
if (window.MathJax?.typesetPromise) {
|
||||
await MathJax.typesetPromise();
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
document.getElementById('page').innerHTML =
|
||||
`<pre>Error loading Markdown: ${err.message}</pre>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Dynamically load a JS file if it exists
|
||||
async function loadPageScript(page) {
|
||||
const jsPath = `/js/${page}.js`; // ✅ declare properly
|
||||
|
||||
try {
|
||||
// HEAD request checks whether the file exists without downloading it
|
||||
const res = await fetch(jsPath, { method: 'HEAD' });
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn(`Script not found: ${jsPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dynamically inject script into the DOM
|
||||
const script = document.createElement('script');
|
||||
script.type = 'module';
|
||||
script.src = jsPath;
|
||||
script.defer = true;
|
||||
|
||||
document.body.appendChild(script);
|
||||
console.log(`Loading script: ${jsPath}`);
|
||||
} catch (err) {
|
||||
console.warn(`Error loading script ${jsPath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Load Nav
|
||||
function loadNav() {
|
||||
fetch('/api/nav')
|
||||
.then(res => res.json())
|
||||
.then(navItems => {
|
||||
const navContainer = document.getElementById('nav');
|
||||
if (!navContainer) return;
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const currentPage = urlParams.get('page') || 'index';
|
||||
|
||||
const enabledItems = navItems.filter(item => item.enabled !== false);
|
||||
|
||||
// --------- FIND BRAND ITEM ----------
|
||||
const brandItem = enabledItems.find(x => x.brand === true);
|
||||
|
||||
// --------- CREATE NAV STRUCTURE ----------
|
||||
const navEl = document.createElement('nav');
|
||||
navEl.className = 'navbar navbar-expand-lg bg-body-tertiary';
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'container-fluid';
|
||||
|
||||
// BRAND
|
||||
if (brandItem) {
|
||||
const brand = document.createElement('a');
|
||||
brand.className = 'navbar-brand';
|
||||
brand.href = `index.html?page=${brandItem.page}`;
|
||||
brand.innerHTML = `<i class="${brandItem.icon} me-2"></i>${brandItem.title}`;
|
||||
container.appendChild(brand);
|
||||
}
|
||||
|
||||
// TOGGLER BUTTON
|
||||
const toggler = document.createElement('button');
|
||||
toggler.className = 'navbar-toggler';
|
||||
toggler.type = 'button';
|
||||
toggler.setAttribute('data-bs-toggle', 'collapse');
|
||||
toggler.setAttribute('data-bs-target', '#navbarNavDropdown');
|
||||
toggler.setAttribute('aria-controls', 'navbarNavDropdown');
|
||||
toggler.setAttribute('aria-expanded', 'false');
|
||||
toggler.setAttribute('aria-label', 'Toggle navigation');
|
||||
toggler.innerHTML = '<span class="navbar-toggler-icon"></span>';
|
||||
container.appendChild(toggler);
|
||||
|
||||
// COLLAPSE REGION
|
||||
const collapse = document.createElement('div');
|
||||
collapse.className = 'collapse navbar-collapse';
|
||||
collapse.id = 'navbarNavDropdown';
|
||||
|
||||
const ul = document.createElement('ul');
|
||||
ul.className = 'navbar-nav me-auto mb-2 mb-lg-0';
|
||||
|
||||
enabledItems.forEach(item => {
|
||||
// skip the brand from the menu
|
||||
if (item.brand === true) return;
|
||||
|
||||
const hasChildren = Array.isArray(item.children) && item.children.length > 0;
|
||||
const li = document.createElement('li');
|
||||
li.className = hasChildren ? 'nav-item dropdown' : 'nav-item';
|
||||
|
||||
// --- NO CHILDREN: NORMAL NAV LINK ---
|
||||
if (!hasChildren) {
|
||||
const a = document.createElement('a');
|
||||
let cls = 'nav-link';
|
||||
if (item.page === currentPage) cls += ' active';
|
||||
|
||||
a.className = cls;
|
||||
a.href = `index.html?page=${item.page}`;
|
||||
a.innerHTML = `<i class="${item.icon} me-2"></i>${item.title}`;
|
||||
|
||||
li.appendChild(a);
|
||||
ul.appendChild(li);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- HAS CHILDREN: DROPDOWN TOGGLE ---
|
||||
const toggle = document.createElement('a');
|
||||
toggle.className = 'nav-link dropdown-toggle';
|
||||
toggle.href = '#';
|
||||
toggle.role = 'button';
|
||||
toggle.setAttribute('data-bs-toggle', 'dropdown');
|
||||
toggle.setAttribute('aria-expanded', 'false');
|
||||
toggle.innerHTML = `<i class="${item.icon} me-2"></i>${item.title}`;
|
||||
|
||||
const menu = document.createElement('ul');
|
||||
menu.className = 'dropdown-menu';
|
||||
|
||||
item.children
|
||||
.filter(child => child.enabled !== false)
|
||||
.forEach(child => {
|
||||
const childLi = document.createElement('li');
|
||||
const childA = document.createElement('a');
|
||||
|
||||
let childClass = 'dropdown-item';
|
||||
if (child.page === currentPage) {
|
||||
childClass += ' active';
|
||||
toggle.classList.add('active'); // highlight parent
|
||||
}
|
||||
|
||||
childA.className = childClass;
|
||||
childA.href = `index.html?page=${child.page}`;
|
||||
childA.innerHTML =
|
||||
`${child.icon ? `<i class="${child.icon} me-2"></i>` : ''}${child.title}`;
|
||||
|
||||
childLi.appendChild(childA);
|
||||
menu.appendChild(childLi);
|
||||
});
|
||||
|
||||
li.appendChild(toggle);
|
||||
li.appendChild(menu);
|
||||
ul.appendChild(li);
|
||||
});
|
||||
|
||||
collapse.appendChild(ul);
|
||||
container.appendChild(collapse);
|
||||
navEl.appendChild(container);
|
||||
|
||||
navContainer.innerHTML = '';
|
||||
navContainer.appendChild(navEl);
|
||||
|
||||
// --------- MANUAL DROPDOWN HANDLERS ---------
|
||||
// const dropdownToggles = navContainer.querySelectorAll('.nav-item.dropdown > .dropdown-toggle');
|
||||
|
||||
// dropdownToggles.forEach(toggle => {
|
||||
// toggle.addEventListener('click', evt => {
|
||||
// evt.preventDefault();
|
||||
|
||||
// const parentLi = toggle.parentElement;
|
||||
// const menu = parentLi.querySelector('.dropdown-menu');
|
||||
// const isShown = menu.classList.contains('show');
|
||||
|
||||
// // close all others
|
||||
// navContainer.querySelectorAll('.dropdown-menu.show').forEach(openMenu => {
|
||||
// openMenu.classList.remove('show');
|
||||
// });
|
||||
|
||||
// // toggle current
|
||||
// if (!isShown) {
|
||||
// menu.classList.add('show');
|
||||
// }
|
||||
// });
|
||||
// });
|
||||
|
||||
// // close dropdown when clicking outside nav
|
||||
// document.addEventListener('click', evt => {
|
||||
// if (!navContainer.contains(evt.target)) {
|
||||
// navContainer.querySelectorAll('.dropdown-menu.show').forEach(openMenu => {
|
||||
// openMenu.classList.remove('show');
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to load nav:', err);
|
||||
const navContainer = document.getElementById('nav');
|
||||
if (navContainer) {
|
||||
navContainer.innerHTML =
|
||||
'<p class="text-danger">Failed to load navigation menu.</p>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
function loadNavTabs() {
|
||||
fetch('/api/nav')
|
||||
.then(res => res.json())
|
||||
.then(navItems => {
|
||||
const ul = document.createElement('ul');
|
||||
ul.className = 'nav nav-tabs';
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const currentPage = urlParams.get('page') || 'index';
|
||||
|
||||
// Filter out disabled items
|
||||
const enabledItems = navItems.filter(item => item.enabled !== false);
|
||||
|
||||
enabledItems.forEach(item => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'nav-item';
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.className = 'nav-link';
|
||||
a.href = `index.html?page=${item.page}`;
|
||||
a.innerHTML = `<i class="${item.icon} me-2"></i>${item.title}`;
|
||||
|
||||
// Apply 'active' class to the current tab
|
||||
if (currentPage === item.page) {
|
||||
a.classList.add('active');
|
||||
a.setAttribute('aria-current', 'page');
|
||||
}
|
||||
|
||||
// Optional: Support for disabled nav items in nav.json
|
||||
if (item.disabled) {
|
||||
a.classList.add('disabled');
|
||||
a.setAttribute('aria-disabled', 'true');
|
||||
}
|
||||
|
||||
li.appendChild(a);
|
||||
ul.appendChild(li);
|
||||
});
|
||||
|
||||
const navContainer = document.getElementById('nav');
|
||||
if (navContainer) {
|
||||
navContainer.innerHTML = ''; // Clear previous content
|
||||
navContainer.appendChild(ul);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Failed to load nav:", err);
|
||||
document.getElementById('nav').innerHTML =
|
||||
'<p class="text-danger">Failed to load navigation menu.</p>';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ===============================
|
||||
// Side Panel (layout-collapsing)
|
||||
// ===============================
|
||||
// async function loadSidePanel() {
|
||||
// // console.log('DEBUG: loadSidePanel() called');
|
||||
|
||||
// const panel = document.getElementById('sidepanel');
|
||||
// const inner = document.getElementById('sidepanel-inner');
|
||||
// const toggleBtn = document.getElementById('sidepanel-toggle');
|
||||
|
||||
// if (!panel || !inner || !toggleBtn) return;
|
||||
|
||||
// const currentPage =
|
||||
// new URLSearchParams(window.location.search).get('page') || 'index';
|
||||
|
||||
// const isCollapsed =
|
||||
// localStorage.getItem('sidepanel_collapsed') === 'true';
|
||||
|
||||
// if (isCollapsed) {
|
||||
// panel.classList.add('collapsed');
|
||||
// // toggleBtn.innerHTML = `<i class="fa-solid fa-folder-plus"></i>`;
|
||||
// toggleBtn.innerHTML = '<i class="bi bi-layout-sidebar"></i>';
|
||||
// } else {
|
||||
// // toggleBtn.innerHTML = `<i class="fa-solid fa-folder-minus"></i>`;
|
||||
// toggleBtn.innerHTML = '<i class="bi bi-layout-sidebar-reverse"></i>';
|
||||
// }
|
||||
|
||||
// try {
|
||||
// const res = await fetch('/api/nav/sidepanel');
|
||||
// if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
// const sideNav = await res.json();
|
||||
|
||||
// renderAllSidePanelSections(inner, sideNav, currentPage);
|
||||
// } catch (err) {
|
||||
// console.error('Failed to load side panel:', err);
|
||||
// inner.innerHTML = '';
|
||||
// }
|
||||
|
||||
// // Collapse / expand behavior
|
||||
// toggleBtn.onclick = () => {
|
||||
// const collapsed = panel.classList.toggle('collapsed');
|
||||
// localStorage.setItem('sidepanel_collapsed', collapsed);
|
||||
|
||||
// // toggleBtn.innerHTML = `
|
||||
// // <i class="fa-solid ${
|
||||
// // collapsed ? 'fa-folder-plus' : 'fa-folder-minus'
|
||||
// // }"></i>
|
||||
// // `;
|
||||
|
||||
// toggleBtn.innerHTML = `
|
||||
// <i class="bi ${
|
||||
// collapsed ? 'bi-layout-sidebar' : 'bi-layout-sidebar-reverse'
|
||||
// }"></i>
|
||||
// `;
|
||||
// };
|
||||
// }
|
||||
async function loadSidePanel() {
|
||||
const panel = document.getElementById('sidepanel');
|
||||
const inner = document.getElementById('sidepanel-inner');
|
||||
const toggleBtn = document.getElementById('sidepanel-toggle');
|
||||
|
||||
if (!panel || !inner || !toggleBtn) return;
|
||||
|
||||
const currentPage =
|
||||
new URLSearchParams(window.location.search).get('page') || 'index';
|
||||
const isCollapsed =
|
||||
localStorage.getItem('sidepanel_collapsed') === 'true';
|
||||
|
||||
if (isCollapsed) {
|
||||
panel.classList.add('collapsed');
|
||||
toggleBtn.innerHTML = '<i class="bi bi-layout-sidebar"></i>';
|
||||
} else {
|
||||
toggleBtn.innerHTML = '<i class="bi bi-layout-sidebar-reverse"></i>';
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/nav/sidepanel');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`); // ✅ Fixed template literal
|
||||
const sideNav = await res.json();
|
||||
renderAllSidePanelSections(inner, sideNav, currentPage);
|
||||
} catch (err) {
|
||||
console.error('Failed to load side panel:', err);
|
||||
inner.innerHTML = '';
|
||||
}
|
||||
|
||||
// Collapse / expand behavior
|
||||
toggleBtn.onclick = () => {
|
||||
const collapsed = panel.classList.toggle('collapsed');
|
||||
localStorage.setItem('sidepanel_collapsed', collapsed);
|
||||
toggleBtn.innerHTML = `
|
||||
<i class="bi ${collapsed ? 'bi-layout-sidebar' : 'bi-layout-sidebar-reverse'}"></i>
|
||||
`;
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------
|
||||
// Find matching section by page
|
||||
// -------------------------------
|
||||
function findSidePanelSection(sideNav, page) {
|
||||
for (const key in sideNav) {
|
||||
const section = sideNav[key];
|
||||
if (!section || section.enabled === false) continue;
|
||||
|
||||
if (Array.isArray(section.items)) {
|
||||
const hit = section.items.find(
|
||||
item => item.enabled !== false && item.page === page
|
||||
);
|
||||
if (hit) return section;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// -------------------------------
|
||||
// Render side panel content
|
||||
// -------------------------------
|
||||
function renderAllSidePanelSections(container, sideNav, currentPage) {
|
||||
container.innerHTML = '';
|
||||
|
||||
Object.values(sideNav)
|
||||
.filter(section => section.enabled !== false)
|
||||
.forEach(section => {
|
||||
|
||||
// ---- Section Header (linkable if page exists) ----
|
||||
const header = document.createElement(
|
||||
section.page ? 'a' : 'div'
|
||||
);
|
||||
|
||||
if (section.desc) {
|
||||
header.title = section.desc;
|
||||
}
|
||||
|
||||
|
||||
header.className =
|
||||
'fw-bold px-3 pt-3 pb-2 small nav-link';
|
||||
// 'fw-bold px-3 pt-3 pb-2 text-uppercase small nav-link';
|
||||
|
||||
if (section.page) {
|
||||
header.href = `index.html?page=${section.page}`;
|
||||
if (section.page === currentPage) {
|
||||
header.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
header.innerHTML = `
|
||||
${section.icon ? `<i class="${section.icon} me-2"></i>` : ''}
|
||||
${section.title}
|
||||
`;
|
||||
|
||||
container.appendChild(header);
|
||||
|
||||
// ---- Section Items (if any) ----
|
||||
if (Array.isArray(section.items) && section.items.length > 0) {
|
||||
const ul = document.createElement('ul');
|
||||
ul.className = 'nav flex-column px-2';
|
||||
|
||||
section.items
|
||||
.filter(item => item.enabled !== false)
|
||||
.forEach(item => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'nav-item';
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.className = 'nav-link py-2';
|
||||
if (item.page === currentPage) {
|
||||
a.classList.add('active');
|
||||
}
|
||||
|
||||
if (item.desc) {
|
||||
a.title = item.desc;
|
||||
}
|
||||
|
||||
a.href = `index.html?page=${item.page}`;
|
||||
a.innerHTML = `
|
||||
${item.icon ? `<i class="${item.icon} me-2"></i>` : ''}
|
||||
${item.title}
|
||||
`;
|
||||
|
||||
li.appendChild(a);
|
||||
ul.appendChild(li);
|
||||
});
|
||||
|
||||
container.appendChild(ul);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// Load Footer
|
||||
function loadFooter() {
|
||||
// Load API Config JSON from /api/config
|
||||
fetch('/api/config')
|
||||
.then(res => res.json())
|
||||
.then(config => {
|
||||
let html = '';
|
||||
html += '<footer class="text-center text-muted py-4">\n';
|
||||
html += '<div class="row">\n';
|
||||
|
||||
html += '<div class="col-2"></div>';
|
||||
|
||||
html += '<div class="col-4">\n';
|
||||
html += ` <a href="${config.package.homepage}" target="_blank" class="">\n`;
|
||||
html += ' <i class="fas fa-square-binary"></i> ' + config.package.description + '\n';
|
||||
html += ' </a><br>\n';
|
||||
html += '</div>\n';
|
||||
|
||||
// Add GitHub link from config.package.homepage
|
||||
html += '<div class="col-4">\n';
|
||||
if (config.package.repository.url) {
|
||||
html += ` <a href="${config.package.repository.url}" target="_blank" class="">\n`;
|
||||
html += ' <i class="fab fa-github fa-lg me-1"></i> ' + config.package.repository.url + '\n';
|
||||
html += ' </a><br>\n';
|
||||
}
|
||||
html += '</div>\n';
|
||||
|
||||
html += '<div class="col-2"></div>';
|
||||
|
||||
html += '<div class="col-3"></div>';
|
||||
// Add copyright
|
||||
html += '<div class="col-2">';
|
||||
if (config.package?.version) {
|
||||
// html += '<br>\n';
|
||||
html += `Version: ${config.package.version}\n`;
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
html += '<div class="col-3">';
|
||||
html += `Copyright © <span class="">${config.package.copyright || ''}</span>`;
|
||||
html += '</div>';
|
||||
|
||||
// Add license if present
|
||||
html += '<div class="col-2">';
|
||||
if (config.package?.license) {
|
||||
html += `License: <a target="_blank" href="${config.package.license_url}">${config.package.license}</a>`;
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
html += '<div class="col-3"></div>';
|
||||
|
||||
html += '</div>\n';
|
||||
|
||||
html += '</footer>\n';
|
||||
// document.getElementById('app-description').innerHTML = config.package.description;
|
||||
document.getElementById('footer').innerHTML = html;
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error loading config:', err);
|
||||
document.getElementById('footer').innerHTML = '<footer class="text-center text-muted py-4">Footer could not be loaded</footer>';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function genBootStrapTable(json_obj, tblsize='1') {
|
||||
|
||||
// json_obj structure
|
||||
// [
|
||||
// [
|
||||
// "value1",
|
||||
// "value2",
|
||||
// "value3"
|
||||
// ],
|
||||
// [
|
||||
// "value1",
|
||||
// "value2",
|
||||
// "value3"
|
||||
// ]
|
||||
// ]
|
||||
|
||||
// console.log("json_obj: ", JSON.stringify(json_obj, null, 2));
|
||||
// console.log("tblsize: ", tblsize);
|
||||
|
||||
// A basic table with rows of content and no headers.
|
||||
let html = '';
|
||||
html += '<table class="table table-hover" style="font-size: ' + tblsize + 'em;">\n';
|
||||
html += '<tbody>\n';
|
||||
for (let row in json_obj) {
|
||||
html += `<tr>\n`;
|
||||
for (let col in json_obj[row]) {
|
||||
html += `<td>${json_obj[row][col]}</td>\n`;
|
||||
}
|
||||
html += `</tr>\n`;
|
||||
}
|
||||
html += '</tbody>\n';
|
||||
html += '</table>\n';
|
||||
return html;
|
||||
}
|
||||
|
||||
// SQL JSON To Bootstrap Table
|
||||
export function sqlJsonToBootstrapTable(json_obj, col_disp_map) {
|
||||
// console.log("Running sqlJsonToBootstrapTable");
|
||||
// console.log("json_obj: ", JSON.stringify(json_obj, null, 2));
|
||||
|
||||
let table_html = '';
|
||||
|
||||
if (json_obj.length == 0) {
|
||||
table_html += "<p>No results found</p>\n";
|
||||
return table_html;
|
||||
} else {
|
||||
|
||||
table_html += "<table class=\"table table-striped table-hover\">\n";
|
||||
|
||||
table_html += "<thead>\n";
|
||||
table_html += "<tr>\n";
|
||||
|
||||
// Get column names
|
||||
let cols = [];
|
||||
|
||||
for (let col in json_obj[0]) {
|
||||
cols.push(col);
|
||||
}
|
||||
|
||||
// console.log("cols: ", JSON.stringify(cols, null, 2));
|
||||
// console.log("col_disp_map: ", JSON.stringify(col_disp_map, null, 2));
|
||||
|
||||
// Add column names to table
|
||||
for (let col in cols) {
|
||||
// table_html += `<th>${cols[col]}</th>\n`;
|
||||
// console.log(`cols[col]: ${cols[col]}`);
|
||||
table_html += `<th>${col_disp_map[cols[col]]}</th>\n`;
|
||||
}
|
||||
table_html += "</tr>\n";
|
||||
table_html += "</thead>\n";
|
||||
|
||||
for (let row in json_obj) {
|
||||
table_html += "<tr>\n";
|
||||
for (let col in cols) {
|
||||
switch (cols[col]) {
|
||||
case 'id':
|
||||
table_html += `
|
||||
<td class="text-center">
|
||||
<input type="checkbox" class="row-select" data-id="${json_obj[row][cols[col]]}">
|
||||
${json_obj[row][cols[col]]}
|
||||
</td>\n`;
|
||||
break;
|
||||
|
||||
case 'balance': {
|
||||
const val = Number(json_obj[row][cols[col]]);
|
||||
if (!isNaN(val)) {
|
||||
const formatted = val.toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
});
|
||||
table_html += `<td>${formatted}</td>\n`;
|
||||
} else {
|
||||
table_html += `<td>${json_obj[row][cols[col]]}</td>\n`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
case 'trans_amount': {
|
||||
const val = Number(json_obj[row][cols[col]]);
|
||||
if (!isNaN(val)) {
|
||||
const formatted = val.toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
});
|
||||
table_html += `<td>${formatted}</td>\n`;
|
||||
} else {
|
||||
table_html += `<td>${json_obj[row][cols[col]]}</td>\n`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'trans_id':
|
||||
// Fixed width, monospace for alignment
|
||||
table_html += `<td style="width:${json_obj[row][cols[col]].length + 2}ch">${json_obj[row][cols[col]]}</td>\n`;
|
||||
// table_html += `<td style="width: 20ch; font-family: monospace;">${json_obj[row][cols[col]]}</td>\n`;
|
||||
break;
|
||||
|
||||
case 'trans_date':
|
||||
// Fixed width, monospace for alignment
|
||||
table_html += `<td style="width:${json_obj[row][cols[col]].length + 2}ch">${json_obj[row][cols[col]]}</td>\n`;
|
||||
// table_html += `<td style="width: 20ch; font-family: monospace;">${json_obj[row][cols[col]]}</td>\n`;
|
||||
break;
|
||||
|
||||
case 'notes':
|
||||
table_html += `<td style="width:25ch">${json_obj[row][cols[col]]}</td>\n`;
|
||||
break;
|
||||
|
||||
default:
|
||||
table_html += `<td>${json_obj[row][cols[col]]}</td>\n`;
|
||||
break;
|
||||
}
|
||||
// table_html += `<td>${json_obj[row][cols[col]]}</td>\n`;
|
||||
}
|
||||
table_html += "</tr>\n";
|
||||
}
|
||||
|
||||
table_html += "</tbody>\n";
|
||||
|
||||
table_html += "</table>\n";
|
||||
|
||||
// console.log("Table HTML: ");
|
||||
// console.log(table_html);
|
||||
|
||||
return table_html;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Load config (always from API)
|
||||
export async function loadConfig() {
|
||||
const res = await fetch('/api/config', { cache: 'no-store' });
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
// Load config
|
||||
// export async function loadConfig() {
|
||||
// const cached = sessionStorage.getItem('ssl_config');
|
||||
// if (cached) {
|
||||
// // console.log('Loaded config from cache');
|
||||
// return JSON.parse(cached);
|
||||
// }
|
||||
|
||||
// const res = await fetch('/api/config');
|
||||
// const config = await res.json();
|
||||
// sessionStorage.setItem('ssl_config', JSON.stringify(config));
|
||||
// // console.log('Fetched config from backend');
|
||||
// return config;
|
||||
// }
|
||||
|
||||
export async function getConfig() {
|
||||
const res = await fetch('/api/config', { cache: 'no-store' });
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
// export async function getConfig() {
|
||||
// const cached = sessionStorage.getItem('ssl_config');
|
||||
// if (!cached) {
|
||||
// throw new Error('ssl_config not found in sessionStorage');
|
||||
// }
|
||||
// return JSON.parse(cached);
|
||||
// }
|
||||
|
||||
|
||||
// // Load tags
|
||||
// export async function loadTags(forceRefresh = false) {
|
||||
// if (!forceRefresh) {
|
||||
// const cached = localStorage.getItem('app_tags');
|
||||
// if (cached) {
|
||||
// // console.log('Loaded tags from cache');
|
||||
// return JSON.parse(cached);
|
||||
// }
|
||||
// }
|
||||
|
||||
// const res = await fetch('/api/sql/query/tags'); // ✅ wait for fetch to resolve
|
||||
// const tags = await res.json(); // ✅ wait for JSON parsing
|
||||
// localStorage.setItem('app_tags', JSON.stringify(tags));
|
||||
// // console.log('Fetched tags from backend');
|
||||
// return tags;
|
||||
// }
|
||||
|
||||
|
||||
// export async function refreshTags() {
|
||||
// const tags = await loadTags(true); // force refresh
|
||||
// // console.log('Tags reloaded:', tags);
|
||||
// }
|
||||
|
||||
|
||||
// export async function tag_badge(tag, active = true) {
|
||||
// const tags = JSON.parse(localStorage.getItem('app_tags')) || [];
|
||||
// const t = tags.find(x => x.name === tag);
|
||||
// if (!t) return `<span class="badge bg-warning" title="Tag not found">${tag}</span>`;
|
||||
|
||||
// const safeName = t.name.replace(/\W+/g, '_');
|
||||
|
||||
// const styleActive = `background-color:${t.color};border-color:${t.color};color:#fff;`;
|
||||
// const styleInactive = `color:${t.color};background-color:#e9ecef;border:1px solid ${t.color};`;
|
||||
// const style = active ? styleActive : styleInactive;
|
||||
|
||||
// return `<span class="tag_${safeName} badge rounded-pill tag-toggle"
|
||||
// data-tag="${t.name}"
|
||||
// data-active="${active}"
|
||||
// style="${style}"
|
||||
// title="${t.description}"
|
||||
// onclick="toggleTag('${t.name.replace(/'/g, "\\'")}')">
|
||||
// ${t.name}
|
||||
// </span>`;
|
||||
// }
|
||||
|
||||
|
||||
|
||||
|
||||
export async function getLocalTimestamp() {
|
||||
const now = new Date();
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const offset = -now.getTimezoneOffset();
|
||||
const sign = offset >= 0 ? '+' : '-';
|
||||
const offsetHours = pad(Math.floor(Math.abs(offset) / 60));
|
||||
const offsetMinutes = pad(Math.abs(offset) % 60);
|
||||
return `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}${sign}${offsetHours}:${offsetMinutes}`;
|
||||
}
|
||||
|
||||
export async function getUTCTimestamp() {
|
||||
const now = new Date();
|
||||
return `${now.getUTCFullYear()}-${pad(now.getUTCMonth()+1)}-${pad(now.getUTCDate())}T${pad(now.getUTCHours())}:${pad(now.getUTCMinutes())}:${pad(now.getUTCSeconds())}Z`;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// ===============================
|
||||
// Load on page load
|
||||
// ===============================
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
// Apply saved theme (default to dark)
|
||||
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-bs-theme', savedTheme);
|
||||
|
||||
const config = await loadConfig();
|
||||
// console.log(`DEBUG: ${JSON.stringify(config)}`)
|
||||
|
||||
// const tags = await loadTags();
|
||||
// console.log(`DEBUG: ${JSON.stringify(tags)}`)
|
||||
|
||||
// Load fragments and layout
|
||||
includeFragment('banner', '/banner.html');
|
||||
// includeFragment('footer', '/footer.html');
|
||||
|
||||
loadNav();
|
||||
|
||||
loadSidePanel();
|
||||
|
||||
loadFooter();
|
||||
|
||||
|
||||
// Load Markdown content
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const page = urlParams.get('page') || 'index';
|
||||
loadMarkdown(`${page}`);
|
||||
|
||||
// Update title and app description
|
||||
if (config?.package?.description) {
|
||||
document.title = config.package.description;
|
||||
|
||||
const descEl = document.querySelector('.app-description');
|
||||
if (descEl) descEl.innerHTML = config.package.description;
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
|
||||
## <span class="app_description"></span>
|
||||
|
||||
- **App:** <span class="app_name"></span>
|
||||
- **Version:** <span class="app_version"></span
|
||||
- **Author:** <span class="app_author"></span>
|
||||
- **Copyright:** <span class="app_copyright"></span>
|
||||
- **License:** <span class="app_license"></span>
|
||||
- **Homepage:** <span class="app_homepage"></span>
|
||||
|
||||
## Node.JS
|
||||
|
||||
<div id="nodejs"></div>
|
||||
|
||||
## Dependancies
|
||||
|
||||
<div id="dependancies"></div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
|
||||
You can access the JSON API Usage / Nav at this link:
|
||||
|
||||
<ul>
|
||||
<li><a target="_blank" id="api-link" href="/api">http://localhost:8411/api</a></li>
|
||||
</ul>
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
# Software Defined Laboratory (SDL)
|
||||
|
||||
| Attribute | Value |
|
||||
| --- | --- |
|
||||
| **Author** | John Haverlack |
|
||||
| **Copyright** | 2026 John Haverlack |
|
||||
| **License** | MIT |
|
||||
| **Version** | 0.3.10 |
|
||||
| **Date** | 2026-02-08 |
|
||||
|
||||
## v0.3.10 - 2026-02-08 (ALPHA)
|
||||
|
||||
- Dash Staging Jobs
|
||||
- Dash Full sdl_id
|
||||
- Updating main Web UI Page
|
||||
- Start v0.3.10
|
||||
|
||||
## v0.3.9 - 2026-02-08 (ALPHA)
|
||||
|
||||
- v0.3.9
|
||||
- Refactor Cluster Status Dash
|
||||
- Refactor Cluster Status Dash
|
||||
- System Columnn Display
|
||||
- Start v0.3.9
|
||||
|
||||
## v0.3.8 - 2026-02-01 (ALPHA)
|
||||
|
||||
- v0.3.8
|
||||
- Refactoing Worker Dashboard
|
||||
- Refactoing Worker Dashboard
|
||||
- Refactored Cluster Status
|
||||
- Refactor of telemetry format + hw detection
|
||||
- Switched Panel Icon to Bootstrap Icon
|
||||
- Updating Worker Telemetry
|
||||
- CP
|
||||
- Fixing Wget/Curl Command Installer Port
|
||||
- Fixing Wget/Curl Command Installer Port
|
||||
- Fixing Wkr Start / Stop
|
||||
- Update stop-sdl-wkr.sh
|
||||
- WIP Install / Start / Stop
|
||||
- WIP Install / Start /Stop
|
||||
- Systemd Detectiion in Start/Stop/Status
|
||||
- WIP: Start / Stop Scripts
|
||||
- Non Systemd start stop scripts
|
||||
- WIP: start stop scripts
|
||||
- Updating Task List
|
||||
- Adding Screeshot to README
|
||||
- Start v0.3.8
|
||||
|
||||
## v0.3.7 - 2026-02-01 (ALPHA)
|
||||
|
||||
**Summary**
|
||||
sdl-wkr Telemetry
|
||||
|
||||
- v0.3.7
|
||||
- Fixing sdl-wkr RAM detection
|
||||
- Start v0.3.7
|
||||
|
||||
## v0.3.6 - 2026-02-01 (ALPHA)
|
||||
|
||||
**Summary**
|
||||
Light/Dark Mode, Wkr Install, Cluster Stats
|
||||
|
||||
- v0.3.6
|
||||
- Adding Cluster Telemetry
|
||||
- WIP: Worker Display
|
||||
- Tweaks
|
||||
- Start v0.3.6
|
||||
- CP Updates
|
||||
- Dash: Total Cluster Stats
|
||||
- Publishing to cluster/workers
|
||||
- Light/Dark Mode Toggle
|
||||
- Added light/dark theme CSS
|
||||
|
||||
## v0.3.5 - 2026-02-01 (ALPHA)
|
||||
|
||||
- v0.3.5
|
||||
- Fixing sdl-wkr install script
|
||||
- Start v0.3.5
|
||||
|
||||
## v0.3.4 - 2026-01-31 (ALPHA)
|
||||
|
||||
- Working sdl-wkr self update
|
||||
- Working sdl-wkr self update
|
||||
- Start v0.3.4
|
||||
|
||||
## v0.3.3 - 2026-01-31 (ALPHA)
|
||||
|
||||
- WIP: sdl-wkr updates
|
||||
- WIP: sdl-wkr updates
|
||||
- Start v0.3.3
|
||||
|
||||
## v0.3.2 - 2026-01-31 (ALPHA)
|
||||
|
||||
- v0.3.2
|
||||
- v0.3.2
|
||||
- Squetching Logs
|
||||
- WORKING: join/authz
|
||||
- WIP: Join Worker
|
||||
- WIP: Join Process
|
||||
- sdl-wkr Cluster Join
|
||||
- Fixing Service Module Render Bug
|
||||
- Start v0.3.2
|
||||
|
||||
## v0.3.1 - 2026-01-31 (ALPHA)
|
||||
|
||||
**Summary**
|
||||
README Cleanup
|
||||
|
||||
- v0.3.1
|
||||
- Start v0.3.1
|
||||
- Removing duplicate LICENSE file
|
||||
- README Cleanup
|
||||
|
||||
## v0.3.0 - 2026-01-31 (ALPHA)
|
||||
|
||||
**Summary**
|
||||
Initial Codebase
|
||||
|
||||
**Notes**
|
||||
Initial GitHub Release
|
||||
|
||||
- Preparing GitHub Version
|
||||
- Start v0.3.0
|
||||
- Initial commit
|
||||
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
|
||||
# Software Defined Laboratory (SDL)
|
||||
|
||||
> NOTE: This is a work in progress. SDL has only been tested on Debian based
|
||||
> Linux systems. But has been designed to be cross platform. So running on
|
||||
> other platforms should not be a heavy lift.
|
||||
|
||||
Other than basic OS tools, SDL downloads all dependencies idempotently and installs
|
||||
everything into a single SDL home directory (~/.sdl).
|
||||
|
||||
---
|
||||
|
||||
## SDL Manager (sdl-mgr)
|
||||
|
||||
#### Installation
|
||||
|
||||
```
|
||||
git clone <SDL_REPO_URL>
|
||||
```
|
||||
|
||||
```
|
||||
cd sdl
|
||||
```
|
||||
|
||||
```
|
||||
./install-sdl.sh
|
||||
```
|
||||
|
||||
#### Upgrading
|
||||
|
||||
```
|
||||
cd sdl
|
||||
```
|
||||
|
||||
```
|
||||
git pull origin main
|
||||
```
|
||||
|
||||
```
|
||||
./install-sdl.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SDL Worker (sdl-wkr)
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
(Debian, Ubuntu, Linux Mint, Zorin OS, Raspbian)
|
||||
|
||||
```
|
||||
sudo apt -y install gawk coreutils curl jq grep sed tar unzip netcat-openbsd
|
||||
```
|
||||
|
||||
#### Installation
|
||||
|
||||
(Linux, OSX, Windows(WSL2))
|
||||
|
||||
Listen to UDP Broadcast on port 10101
|
||||
- To get the **sld-wkr** curl or wget install command
|
||||
|
||||
```
|
||||
nc -u -l -k 10101
|
||||
```
|
||||
|
||||
OR
|
||||
|
||||
```
|
||||
nc -u -l -k 10101 |jq
|
||||
{
|
||||
"ts": "2026-01-31T05:20:31.815Z",
|
||||
"sdl_id": "906a027c-52f0-4cbb-b3e0-5eb84856d50a",
|
||||
"role": "sdl-mgr",
|
||||
"host": "haverlab-sld-mgr",
|
||||
"type": "udp-beacon",
|
||||
"msg": {
|
||||
"info": {
|
||||
"desc": "Software Defined Laboratory: Manager (sdl-mgr)",
|
||||
"version": "0.2.10",
|
||||
"copyright": "2026 John Haverlack"
|
||||
},
|
||||
"cluster": {
|
||||
"id": "haverlab",
|
||||
"name": "Haverlab",
|
||||
"desc": "John Haverlack's Home Lab"
|
||||
},
|
||||
"mqtt": {
|
||||
"host": "10.0.0.178",
|
||||
"port": 1883
|
||||
},
|
||||
"web": {
|
||||
"proto": "http",
|
||||
"host": "10.0.0.178",
|
||||
"port": 8081,
|
||||
"api_config": "/api/config",
|
||||
"config_url": "http://10.0.0.178:8081/api/config"
|
||||
},
|
||||
"sdl_wkr_install_cmd": {
|
||||
"curl": "curl -s http://10.0.0.178:8081/dist/install-sdl-wkr.sh | bash -s 10.0.0.178",
|
||||
"wget": "wget -O - http://10.0.0.178:8081/dist/install-sdl-wkr.sh | bash -s 10.0.0.178"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
curl -s http://<SDL_MGR_IP>:<WEB_PORT>/dist/install-sdl-wkr.sh | bash -s <SDL_MGR_IP>
|
||||
```
|
||||
|
||||
#### Upgrading
|
||||
|
||||
SDL Workers continuously monitor the SDL Manager’s advertised version via MQTT cluster status messages and will automatically re-install and restart when a newer version is detected.
|
||||
|
||||
Alternatively you can upgrade manually by rerunning the install-sdl-wkr.sh script.
|
||||
|
||||
```
|
||||
curl -s http://<SDL_MGR_IP>:<WEB_PORT>/dist/install-sdl-wkr.sh | bash -s <SDL_MGR_IP>
|
||||
```
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
# Node Web App (NWA)
|
||||
|
||||
Node Web App (NWA) is a Node.js application template for building fully self-hosted web applications with no CDN or Internet dependencies.
|
||||
|
||||
NWA provides a single-service application structure that includes a web API, JSON-based configuration, configurable navigation menus, sidebar navigation, responsive Bootstrap UI, and integrated NPM libraries.
|
||||
|
||||
The project is designed to be modular and extensible, allowing additional components such as SQLite support, MQTT broker integration, and other service modules to be added cleanly.
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<div class="container-fluid mt-4">
|
||||
<div class="row">
|
||||
<div class="col-lg-6 col-md-8">
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Appearance</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-8">
|
||||
<label for="themeSwitch" class="form-label mb-1">
|
||||
<i class="fas fa-moon me-2"></i>Dark Mode
|
||||
</label>
|
||||
<p class="text-muted small mb-0">Toggle between light and dark theme</p>
|
||||
</div>
|
||||
<div class="col-4 text-end">
|
||||
<div class="form-check form-switch d-inline-block">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="themeSwitch" checked>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
[Unit]
|
||||
Description=SDL Manager
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
# Paths
|
||||
Environment=SDL_HOME=%h/.sdl
|
||||
|
||||
WorkingDirectory=%h/.sdl/sdl-mgr/current/app
|
||||
|
||||
# Start command
|
||||
ExecStart=%h/.sdl/nodejs/current/bin/node index.js
|
||||
|
||||
# Restart behavior
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
|
||||
# Logging
|
||||
StandardOutput=append:%h/.sdl/logs/sdl-mgr.out.log
|
||||
StandardError=append:%h/.sdl/logs/sdl-mgr.err.log
|
||||
|
||||
# Clean shutdown
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=15
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
#!/usr/bin/env bash
|
||||
# start-sdl-mgr.sh - Script to start SDL Manager
|
||||
set -euo pipefail
|
||||
|
||||
use_systemd() {
|
||||
# Check if systemd user mode is available and service exists
|
||||
if systemctl --user status >/dev/null 2>&1; then
|
||||
# Check if our service file exists
|
||||
if systemctl --user list-unit-files | grep -q "^sdl-mgr.service"; then
|
||||
return 0 # Use systemd
|
||||
fi
|
||||
fi
|
||||
return 1 # Use manual scripts
|
||||
}
|
||||
|
||||
# Get $SDL_HOME directory from SCRIPT PATH
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SDL_HOME="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
if use_systemd; then
|
||||
# Systemd mode - let systemd handle it
|
||||
echo "Starting SDL Manager (systemd)..."
|
||||
systemctl --user start sdl-mgr
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Manual mode - continue with checks
|
||||
NODE_BIN_DIR="${SDL_HOME}/nodejs/current/bin"
|
||||
NODE_BIN="${NODE_BIN_DIR}/node"
|
||||
NPM_BIN="${NODE_BIN_DIR}/npm"
|
||||
|
||||
SDL_MGR_APP_DIR="${SDL_HOME}/sdl-mgr/current/app"
|
||||
|
||||
# Check that sdl-mgr is not already running
|
||||
SDL_MGR_PID=""
|
||||
for pid in $(pgrep -f 'node' 2>/dev/null || true); do
|
||||
cwd=$(readlink /proc/$pid/cwd 2>/dev/null || true)
|
||||
if [[ "$cwd" == *"sdl-mgr"* ]]; then
|
||||
SDL_MGR_PID="$pid"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -n "$SDL_MGR_PID" ]]; then
|
||||
echo "ERROR: SDL Manager is already running with PID ${SDL_MGR_PID}"
|
||||
echo " Stop it first: ${SDL_HOME}/scripts/stop-sdl-mgr.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify paths exist
|
||||
if [[ ! -f "$NODE_BIN" ]]; then
|
||||
echo "ERROR: Node.js not found at: $NODE_BIN"
|
||||
echo " Run install-sdl.sh first"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$SDL_MGR_APP_DIR" ]]; then
|
||||
echo "ERROR: SDL Manager app not found at: $SDL_MGR_APP_DIR"
|
||||
echo " Run install-sdl.sh first"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Starting SDL Manager..."
|
||||
echo " App Dir: ${SDL_MGR_APP_DIR}"
|
||||
echo " Node:
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
#!/usr/bin/env bash
|
||||
# status-sdl-mgr.sh - Script to show status of SDL Manager
|
||||
set -euo pipefail
|
||||
|
||||
use_systemd() {
|
||||
# Check if systemd user mode is available and service exists
|
||||
if systemctl --user status >/dev/null 2>&1; then
|
||||
# Check if our service file exists
|
||||
if systemctl --user list-unit-files | grep -q "^sdl-mgr.service"; then
|
||||
return 0 # Use systemd
|
||||
fi
|
||||
fi
|
||||
return 1 # Use manual scripts
|
||||
}
|
||||
|
||||
if use_systemd; then
|
||||
systemctl --user status sdl-mgr
|
||||
else
|
||||
# Get sdl-mgr PID
|
||||
SDL_MGR_PID=""
|
||||
for pid in $(pgrep -f 'node' 2>/dev/null || true); do
|
||||
cwd=$(readlink /proc/$pid/cwd 2>/dev/null || true)
|
||||
if [[ "$cwd" == *"sdl-mgr"* ]]; then
|
||||
SDL_MGR_PID="$pid"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z "$SDL_MGR_PID" ]]; then
|
||||
echo "SDL Manager is not running"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Get process uptime
|
||||
PROCESS_START=$(ps -p "$SDL_MGR_PID" -o lstart= 2>/dev/null || echo "unknown")
|
||||
|
||||
echo "SDL Manager is running"
|
||||
echo " PID: ${SDL_MGR_PID}"
|
||||
echo " Started: ${PROCESS_START}"
|
||||
exit 0
|
||||
fi
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
#!/usr/bin/env bash
|
||||
# stop-sdl-mgr.sh - Script to stop SDL Manager
|
||||
set -euo pipefail
|
||||
|
||||
use_systemd() {
|
||||
# Check if systemd user mode is available and service exists
|
||||
if systemctl --user status >/dev/null 2>&1; then
|
||||
# Check if our service file exists
|
||||
if systemctl --user list-unit-files | grep -q "^sdl-mgr.service"; then
|
||||
return 0 # Use systemd
|
||||
fi
|
||||
fi
|
||||
return 1 # Use manual scripts
|
||||
}
|
||||
|
||||
if use_systemd; then
|
||||
# Systemd mode - let systemd handle it
|
||||
echo "Stopping SDL Manager (systemd)..."
|
||||
systemctl --user stop sdl-mgr --quiet 2>/dev/null || true
|
||||
echo "SDL Manager stopped"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Manual mode - find and kill process
|
||||
SDL_MGR_PID=""
|
||||
for pid in $(pgrep -f 'node' 2>/dev/null || true); do
|
||||
cwd=$(readlink /proc/$pid/cwd 2>/dev/null || true)
|
||||
if [[ "$cwd" == *"sdl-mgr"* ]]; then
|
||||
SDL_MGR_PID="$pid"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z "$SDL_MGR_PID" ]]; then
|
||||
echo "SDL Manager is not running"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Stopping SDL Manager (PID: ${SDL_MGR_PID})..."
|
||||
|
||||
# Send SIGTERM for graceful shutdown
|
||||
kill "$SDL_MGR_PID"
|
||||
|
||||
# Wait up to 10 seconds for process to exit
|
||||
for i in {1..10}; do
|
||||
if ! kill -0 "$SDL_MGR_PID" 2>/dev/null; then
|
||||
echo "SDL Manager stopped successfully"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# If still running after 10 seconds, force kill
|
||||
if kill -0 "$SDL_MGR_PID" 2>/dev/null; then
|
||||
echo "WARNING: Graceful shutdown failed, forcing kill..."
|
||||
kill -9 "$SDL_MGR_PID"
|
||||
sleep 1
|
||||
|
||||
if kill -0 "$SDL_MGR_PID" 2>/dev/null; then
|
||||
echo "ERROR: Failed to stop SDL Manager"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "SDL Manager stopped"
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
#!/usr/bin/env bash
|
||||
# changelog-update.sh
|
||||
#
|
||||
# Idempotently maintain the changelog entry for the active version.
|
||||
# - Updates METADATA.version_date
|
||||
# - Syncs maturity
|
||||
# - Ensures exactly one entry per version
|
||||
# - Updates or creates the active changelog entry
|
||||
# - Regenerates docs/CHANGELOG.md
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VERSION=$(jq -r '.METADATA.version' metadata.json)
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
MATURITY=$(jq -r '.METADATA.maturity' metadata.json)
|
||||
|
||||
tmp_meta=$(mktemp)
|
||||
tmp_changelog=$(mktemp)
|
||||
trap 'rm -f "$tmp_meta" "$tmp_changelog"' EXIT
|
||||
|
||||
# -------------------------------------------------
|
||||
# Update version_date in metadata.json
|
||||
# -------------------------------------------------
|
||||
jq --arg d "$DATE" '
|
||||
.METADATA.version_date = $d
|
||||
' metadata.json > "$tmp_meta"
|
||||
|
||||
mv "$tmp_meta" metadata.json
|
||||
|
||||
# Propagate metadata into files
|
||||
./tools/metadata-update.sh
|
||||
|
||||
# -------------------------------------------------
|
||||
# Ensure changelog.json exists and is valid
|
||||
# -------------------------------------------------
|
||||
if [[ ! -f changelog.json ]]; then
|
||||
echo '{"releases":[]}' > changelog.json
|
||||
fi
|
||||
|
||||
if ! jq -e '.releases | type == "array"' changelog.json >/dev/null 2>&1; then
|
||||
echo "ERROR: changelog.json must contain a single 'releases' array"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# -------------------------------------------------
|
||||
# Normalize: enforce one entry per version (keep newest)
|
||||
# -------------------------------------------------
|
||||
# jq '
|
||||
# .releases |=
|
||||
# ( . | reverse
|
||||
# | unique_by(.version)
|
||||
# | reverse
|
||||
# )
|
||||
# ' changelog.json > "$tmp_changelog"
|
||||
jq '
|
||||
def semver:
|
||||
(.version
|
||||
| split(".")
|
||||
| map(tonumber));
|
||||
|
||||
.releases |=
|
||||
(
|
||||
sort_by(semver) # oldest → newest
|
||||
| reverse # newest → oldest
|
||||
| unique_by(.version) # keep newest entry per version
|
||||
| sort_by(semver)
|
||||
| reverse
|
||||
)
|
||||
' changelog.json > "$tmp_changelog"
|
||||
|
||||
|
||||
mv "$tmp_changelog" changelog.json
|
||||
|
||||
# -------------------------------------------------
|
||||
# Update or add active version entry
|
||||
# -------------------------------------------------
|
||||
if jq -e --arg v "$VERSION" '.releases[] | select(.version == $v)' changelog.json >/dev/null; then
|
||||
echo "Updating changelog entry for v${VERSION}"
|
||||
|
||||
jq --arg v "$VERSION" --arg d "$DATE" --arg m "$MATURITY" '
|
||||
.releases |=
|
||||
map(
|
||||
if .version == $v then
|
||||
.date = $d
|
||||
| .maturity = $m
|
||||
else
|
||||
.
|
||||
end
|
||||
)
|
||||
' changelog.json > "$tmp_changelog"
|
||||
else
|
||||
echo "Adding changelog entry for v${VERSION}"
|
||||
|
||||
jq --arg v "$VERSION" --arg d "$DATE" --arg m "$MATURITY" '
|
||||
.releases |=
|
||||
([{
|
||||
version: $v,
|
||||
date: $d,
|
||||
maturity: $m,
|
||||
summary: "",
|
||||
notes: ""
|
||||
}] + .)
|
||||
' changelog.json > "$tmp_changelog"
|
||||
fi
|
||||
|
||||
mv "$tmp_changelog" changelog.json
|
||||
|
||||
# -------------------------------------------------
|
||||
# Regenerate Markdown changelog
|
||||
# -------------------------------------------------
|
||||
./tools/genmd-changelog.sh
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
#!/usr/bin/env bash
|
||||
# genmd-changelog.sh
|
||||
#
|
||||
# Generate docs/CHANGELOG.md from changelog.json, metadata.json,
|
||||
# and Git merge history.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
OUT_FILE="docs/CHANGELOG.md"
|
||||
|
||||
echo "Generating ${OUT_FILE}"
|
||||
|
||||
# -------------------------------------------------
|
||||
# Load metadata for header
|
||||
# -------------------------------------------------
|
||||
NAME=$(jq -r '.METADATA.description' metadata.json)
|
||||
ABBR=$(jq -r '.METADATA.abbr' metadata.json)
|
||||
VERSION=$(jq -r '.METADATA.version' metadata.json)
|
||||
DATE=$(jq -r '.METADATA.version_date' metadata.json)
|
||||
AUTHOR=$(jq -r '.METADATA.author' metadata.json)
|
||||
COPYRIGHT=$(jq -r '.METADATA.copyright' metadata.json)
|
||||
LICENSE=$(jq -r '.METADATA.license' metadata.json)
|
||||
|
||||
# -------------------------------------------------
|
||||
# Collect release merge commits (newest → oldest)
|
||||
# -------------------------------------------------
|
||||
mapfile -t MERGES < <(
|
||||
git log main --merges --pretty=format:'%H|%s' |
|
||||
grep -E "(Merge branch 'v|Release v)"
|
||||
)
|
||||
|
||||
find_merge() {
|
||||
local tag="v$1"
|
||||
for entry in "${MERGES[@]}"; do
|
||||
if [[ "$entry" == *"Merge branch '${tag}'"* ]] || [[ "$entry" == *"Release ${tag}"* ]]; then
|
||||
echo "${entry%%|*}"
|
||||
return
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# -------------------------------------------------
|
||||
# Write header (README-style)
|
||||
# -------------------------------------------------
|
||||
{
|
||||
echo "# ${NAME} (${ABBR})"
|
||||
echo ""
|
||||
echo "| Attribute | Value |"
|
||||
echo "| --- | --- |"
|
||||
echo "| **Author** | ${AUTHOR} |"
|
||||
echo "| **Copyright** | ${COPYRIGHT} |"
|
||||
echo "| **License** | ${LICENSE} |"
|
||||
echo "| **Version** | ${VERSION} |"
|
||||
echo "| **Date** | ${DATE} |"
|
||||
echo ""
|
||||
} > "$OUT_FILE"
|
||||
|
||||
# -------------------------------------------------
|
||||
# Iterate releases (SEMVER newest → oldest)
|
||||
# -------------------------------------------------
|
||||
jq -r '
|
||||
def semver:
|
||||
(.version | split(".") | map(tonumber));
|
||||
|
||||
.releases
|
||||
| sort_by(semver)
|
||||
| reverse
|
||||
| .[]
|
||||
| "\(.version)|\(.date)|\(.maturity)|\(.summary)|\(.notes)"
|
||||
' changelog.json |
|
||||
while IFS='|' read -r version rdate maturity summary notes; do
|
||||
tag="v${version}"
|
||||
merge_commit=$(find_merge "$version")
|
||||
|
||||
echo "## ${tag} - ${rdate} (${maturity})" >> "$OUT_FILE"
|
||||
echo "" >> "$OUT_FILE"
|
||||
|
||||
if [[ -n "$summary" ]]; then
|
||||
echo "**Summary**" >> "$OUT_FILE"
|
||||
echo "$summary" >> "$OUT_FILE"
|
||||
echo "" >> "$OUT_FILE"
|
||||
fi
|
||||
|
||||
if [[ -n "$notes" ]]; then
|
||||
echo "**Notes**" >> "$OUT_FILE"
|
||||
echo "$notes" >> "$OUT_FILE"
|
||||
echo "" >> "$OUT_FILE"
|
||||
fi
|
||||
|
||||
# -------------------------------------------------
|
||||
# Commit list
|
||||
# -------------------------------------------------
|
||||
if [[ -n "$merge_commit" ]]; then
|
||||
# Released version: merge-boundary commits
|
||||
lower_bound=""
|
||||
for ((i=0; i<${#MERGES[@]}; i++)); do
|
||||
if [[ "${MERGES[$i]}" == "${merge_commit}"* ]]; then
|
||||
if (( i + 1 < ${#MERGES[@]} )); then
|
||||
lower_bound="${MERGES[$((i+1))]%%|*}"
|
||||
fi
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -n "$lower_bound" ]]; then
|
||||
git log "${lower_bound}..${merge_commit}" \
|
||||
--no-merges \
|
||||
--pretty=format:"- %s" >> "$OUT_FILE"
|
||||
else
|
||||
git log "${merge_commit}" \
|
||||
--no-merges \
|
||||
--pretty=format:"- %s" >> "$OUT_FILE"
|
||||
fi
|
||||
else
|
||||
# Active (not yet merged) version: commits since last release
|
||||
if [[ ${#MERGES[@]} -gt 0 ]]; then
|
||||
last_merge="${MERGES[0]%%|*}"
|
||||
git log "${last_merge}..HEAD" \
|
||||
--no-merges \
|
||||
--pretty=format:"- %s" >> "$OUT_FILE"
|
||||
else
|
||||
git log HEAD \
|
||||
--no-merges \
|
||||
--pretty=format:"- %s" >> "$OUT_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "" >> "$OUT_FILE"
|
||||
echo "" >> "$OUT_FILE"
|
||||
done
|
||||
|
||||
cp docs/CHANGELOG.md sdl-mgr/html/md/changelog.md
|
||||
|
||||
echo "Changelog written to ${OUT_FILE}"
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
#!/usr/bin/env bash
|
||||
# main-merge.sh - Merge release branch into main, tag, and delete branch
|
||||
set -euo pipefail
|
||||
|
||||
CUR_VER=$(jq -r '.METADATA.version' metadata.json)
|
||||
CUR_BRANCH=$(git branch --show-current)
|
||||
TAG="v${CUR_VER}"
|
||||
|
||||
# Ensure correct branch
|
||||
if [[ "$CUR_BRANCH" != "$TAG" ]]; then
|
||||
echo "You must be on branch $TAG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure clean working tree
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
echo "There are uncommitted changes"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure tag does not already exist
|
||||
if git show-ref --verify --quiet "refs/tags/$TAG"; then
|
||||
echo "Tag $TAG already exists"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Switch to main
|
||||
git checkout main
|
||||
if [[ "$(git branch --show-current)" != "main" ]]; then
|
||||
echo "Failed to switch to main"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Merge release branch
|
||||
git merge --no-ff "$CUR_BRANCH" -m "Release $TAG"
|
||||
|
||||
# Create annotated tag
|
||||
git tag -a "$TAG" -m "Release $TAG"
|
||||
|
||||
# Delete release branch
|
||||
git branch -d "$CUR_BRANCH"
|
||||
|
||||
echo "Release $TAG merged, tagged, and branch deleted"
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
#!/usr/bin/env bash
|
||||
# metadata-update.sh - Update SDL metadata in package.json and Markdown files
|
||||
set -euo pipefail
|
||||
|
||||
# -----------------------------
|
||||
# Resolve paths
|
||||
# -----------------------------
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SDL_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
META_FILE="$SDL_ROOT/metadata.json"
|
||||
|
||||
command -v jq >/dev/null || { echo "jq required"; exit 1; }
|
||||
command -v sed >/dev/null || { echo "sed required"; exit 1; }
|
||||
|
||||
# -----------------------------
|
||||
# Helpers
|
||||
# -----------------------------
|
||||
get_meta() {
|
||||
jq -r ".METADATA.$1" "$META_FILE"
|
||||
}
|
||||
|
||||
escape_regex() {
|
||||
printf '%s' "$1" | sed -e 's/[][\/.^$*+?{}|()]/\\&/g'
|
||||
}
|
||||
|
||||
# -----------------------------
|
||||
# Update package.json
|
||||
# -----------------------------
|
||||
update_package_json() {
|
||||
local file="$1"
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
|
||||
jq \
|
||||
--argfile meta "$META_FILE" \
|
||||
'
|
||||
. as $pkg
|
||||
| ($meta.META_MAPS["package.json"] | to_entries) as $maps
|
||||
| reduce $maps[] as $m ($pkg;
|
||||
.[$m.key] = $meta.METADATA[$m.value]
|
||||
)
|
||||
' "$file" > "$tmp"
|
||||
|
||||
mv "$tmp" "$file"
|
||||
echo "Updated $file"
|
||||
}
|
||||
|
||||
# -----------------------------
|
||||
# Update Markdown metadata table
|
||||
# -----------------------------
|
||||
update_markdown() {
|
||||
local file="$1"
|
||||
|
||||
jq -r '.META_MAPS["*.md"] | to_entries[] | "\(.key)|\(.value)"' "$META_FILE" |
|
||||
while IFS='|' read -r label meta_key; do
|
||||
value="$(get_meta "$meta_key")"
|
||||
esc_label="$(escape_regex "$label")"
|
||||
|
||||
sed -i -E \
|
||||
"s#^\\|[[:space:]]*${esc_label}[[:space:]]*\\|[[:space:]]*[^|]*\\|#| ${label} | $value |#" \
|
||||
"$file"
|
||||
done
|
||||
|
||||
echo "Updated $file"
|
||||
}
|
||||
|
||||
# -----------------------------
|
||||
# Main
|
||||
# -----------------------------
|
||||
jq -r '.MANIFEST[]' "$META_FILE" | while read -r rel_path; do
|
||||
file="$SDL_ROOT/$rel_path"
|
||||
|
||||
if [[ ! -f "$file" ]]; then
|
||||
echo "WARN: $rel_path not found, skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
case "$(basename "$file")" in
|
||||
package.json)
|
||||
update_package_json "$file"
|
||||
;;
|
||||
*.md)
|
||||
update_markdown "$file"
|
||||
;;
|
||||
*)
|
||||
echo "WARN: No handler for $rel_path"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "Metadata update complete"
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
#!/usr/bin/env bash
|
||||
# new-version.sh - Create new release branch and update metadata
|
||||
set -euo pipefail
|
||||
|
||||
# -------------------------------------------------
|
||||
# Preconditions
|
||||
# -------------------------------------------------
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
echo "Working tree is not clean"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# -------------------------------------------------
|
||||
# Helpers
|
||||
# -------------------------------------------------
|
||||
suggest_next_version() {
|
||||
local cur
|
||||
cur=$(jq -r '.METADATA.version' metadata.json)
|
||||
IFS='.' read -ra parts <<< "$cur"
|
||||
echo "${parts[0]}.${parts[1]}.$((parts[2] + 1))"
|
||||
}
|
||||
|
||||
# -------------------------------------------------
|
||||
# Load metadata
|
||||
# -------------------------------------------------
|
||||
DESC=$(jq -r '.METADATA.description' metadata.json)
|
||||
ABBR=$(jq -r '.METADATA.abbr' metadata.json)
|
||||
COPYRT=$(jq -r '.METADATA.copyright' metadata.json)
|
||||
CUR_VER=$(jq -r '.METADATA.version' metadata.json)
|
||||
CUR_VER_DATE=$(jq -r '.METADATA.version_date' metadata.json)
|
||||
NXT_VER=$(suggest_next_version)
|
||||
|
||||
echo "Description: $DESC ($ABBR)"
|
||||
echo "Copyright: (C) $COPYRT"
|
||||
echo "Current version: $CUR_VER"
|
||||
echo "Current version date: $CUR_VER_DATE"
|
||||
echo ""
|
||||
|
||||
read -p "New version: [$NXT_VER]: " NEW_VER
|
||||
if [[ -z "$NEW_VER" ]]; then
|
||||
NEW_VER="$NXT_VER"
|
||||
fi
|
||||
|
||||
NEW_VER_DATE=$(date +%Y-%m-%d)
|
||||
|
||||
echo ""
|
||||
echo "New version: $NEW_VER"
|
||||
echo "New version date: $NEW_VER_DATE"
|
||||
echo ""
|
||||
|
||||
read -p "Are you sure? (Y/n) " -n 1 -r
|
||||
echo ""
|
||||
if [[ $REPLY =~ ^[Nn]$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# -------------------------------------------------
|
||||
# Safety checks
|
||||
# -------------------------------------------------
|
||||
if git show-ref --verify --quiet "refs/heads/v${NEW_VER}"; then
|
||||
echo "Branch v${NEW_VER} already exists"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# -------------------------------------------------
|
||||
# Update metadata.json (structurally)
|
||||
# -------------------------------------------------
|
||||
tmp_meta=$(mktemp)
|
||||
trap 'rm -f "$tmp_meta"' EXIT
|
||||
|
||||
jq --arg v "$NEW_VER" --arg d "$NEW_VER_DATE" '
|
||||
.METADATA.version = $v
|
||||
| .METADATA.version_date = $d
|
||||
' metadata.json > "$tmp_meta"
|
||||
|
||||
mv "$tmp_meta" metadata.json
|
||||
|
||||
# -------------------------------------------------
|
||||
# Create branch
|
||||
# -------------------------------------------------
|
||||
git checkout -b "v${NEW_VER}"
|
||||
|
||||
# -------------------------------------------------
|
||||
# Propagate metadata and commit
|
||||
# -------------------------------------------------
|
||||
./tools/metadata-update.sh
|
||||
|
||||
git commit -am "Start v${NEW_VER}"
|
||||
|
||||
echo "Created new release branch v${NEW_VER}"
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import socket
|
||||
import sys
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: python udp_listener.py <port>")
|
||||
return
|
||||
|
||||
port = int(sys.argv[1])
|
||||
|
||||
# Create a datagram socket
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
except socket.error as msg:
|
||||
print('Failed to create socket. ' + str(msg))
|
||||
return
|
||||
|
||||
# Enable broadcasting mode
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
|
||||
try:
|
||||
# Bind the socket to a specific port
|
||||
sock.bind(('', port))
|
||||
except socket.error as msg:
|
||||
print('Bind failed. ' + str(msg))
|
||||
return
|
||||
|
||||
while True:
|
||||
data, addr = sock.recvfrom(1024)
|
||||
print(data.decode())
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in New Issue