Initial Commit Migrating NWA from SDL

This commit is contained in:
John Haverlack 2026-06-28 09:08:41 -08:00
commit 709d450f0d
68 changed files with 8200 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
docs/.obsidian

21
LICENSE.md Normal file
View File

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

72
README.md Normal file
View File

@ -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
![SDL Dashboard](docs/lib/img/sdl-dash.png)
![SDL About](docs/lib/img/sdl-about.png)
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.

81
changelog.json Normal file
View File

@ -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"
}
]
}

22
config.json Normal file
View File

@ -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"
]
}
}

126
docs/CHANGELOG.md Normal file
View File

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

314
docs/DESIGN.md Normal file
View File

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

119
docs/INSTALL.md Normal file
View File

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

61
docs/WORKFLOW.md Normal file
View File

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

BIN
docs/lib/img/sdl-about.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

BIN
docs/lib/img/sdl-dash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

View File

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

View File

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

View File

@ -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": {}
}

437
install.sh Executable file
View File

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

41
metadata.json Normal file
View File

@ -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"
]
}

5
nwa/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
conf/secrets/*
app/node_modules/*
data/*
logs/*
**/.~lock*#

9
nwa/LICENSE.md Normal file
View File

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

72
nwa/README.md Normal file
View File

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

22
nwa/app/index.js Normal file
View File

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

View File

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

View File

@ -0,0 +1,4 @@
{
"main": "index.js",
"type": "module"
}

View File

@ -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}`);

View File

@ -0,0 +1,4 @@
{
"main": "index.js",
"type": "module"
}

View File

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

View File

@ -0,0 +1,4 @@
{
"main": "index.js",
"type": "module"
}

3084
nwa/app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
nwa/app/package.json Normal file
View File

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

View File

@ -0,0 +1,3 @@
{
"enabled": true
}

21
nwa/conf/modules/web.json Normal file
View File

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

23
nwa/conf/nwa-config.json Normal file
View File

@ -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": {}
}
}

5
nwa/conf/nwa-id.json Normal file
View File

@ -0,0 +1,5 @@
{
"id": "1a381c70-9c9d-41d6-b3f3-41b2b2dcb201",
"created_at": "2026-06-28T16:46:10.204Z",
"hostname": "jehaverlack-ws-0"
}

13
nwa/html/404.html Normal file
View File

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

9
nwa/html/banner.html Normal file
View File

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

View File

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

49
nwa/html/conf/nav.json Normal file
View File

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

View File

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

36
nwa/html/css/nwa.css Normal file
View File

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

BIN
nwa/html/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

View File

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

BIN
nwa/html/img/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

View File

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

92
nwa/html/index.html Normal file
View File

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

97
nwa/html/js/about.js Normal file
View File

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

6
nwa/html/js/api.js Normal file
View File

@ -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
nwa/html/js/docs.js Normal file
View File

1
nwa/html/js/index.js Normal file
View File

@ -0,0 +1 @@

0
nwa/html/js/proj.js Normal file
View File

50
nwa/html/js/settings.js Normal file
View File

@ -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';
}
}
}

913
nwa/html/js/weblib.js Normal file
View File

@ -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 &copy; <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;
}
});

22
nwa/html/md/about.md Normal file
View File

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

6
nwa/html/md/api.md Normal file
View File

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

126
nwa/html/md/changelog.md Normal file
View File

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

119
nwa/html/md/docs.md Normal file
View File

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

8
nwa/html/md/index.md Normal file
View File

@ -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
nwa/html/md/proj.md Normal file
View File

26
nwa/html/md/settings.md Normal file
View File

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

View File

@ -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
nwa/scripts/sdl-uninstall.sh Executable file
View File

65
nwa/scripts/start-sdl-mgr.sh Executable file
View File

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

41
nwa/scripts/status-sdl-mgr.sh Executable file
View File

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

65
nwa/scripts/stop-sdl-mgr.sh Executable file
View File

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

111
tools/changelog-update.sh Executable file
View File

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

134
tools/genmd-changelog.sh Executable file
View File

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

44
tools/main-merge.sh Executable file
View 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"

91
tools/metadata-update.sh Executable file
View File

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

90
tools/new-version.sh Executable file
View File

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

33
tools/udp-bcast-listen.py Normal file
View File

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