Start v0.3.0

This commit is contained in:
John Haverlack 2026-01-31 13:45:39 -09:00
parent bafa565131
commit 53f095146e
86 changed files with 7508 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
dist/*
sdl-home/logs/*
sdl-home/data/*
sdl-id.json

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.

71
README.md Normal file
View File

@ -0,0 +1,71 @@
# Software Defined Laboratory (SDL)
| Attribute | Value |
| --- | --- |
| **Author** | John Haverlack |
| **Copyright** | 2026 John Haverlack |
| **License** | MIT |
| **Version** | 0.3.0 |
| **Date** | 2026-01-31 |
## Overview
The Software Defined Laboratory (SDL) project provides a generalized distributed computing platform for managing parallel computational workflows across a cluster of 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.
## Design
For more information, see the [Design](docs/DESIGN.md) document.
## Getting Started
## SDL Manager Installation
```
$ git clone https://github.com/jhaverlack/sdl.git
$ cd sdl
$ install-sdl.sh
```
> sdl-mgr is installed to $HOME/.sdl/sdl-mgr
sdl-mgr will be:
- installed to $HOME/.sdl/sdl-mgr
- run as a non-root user (e.g., `sdl-mgr@USER`)
- sets up USER based systemd service
### System D Process
> sdl-mgr runs as a non-root user (e.g., `sdl-mgr@USER`)
```
$ sudo systemctl start sdl-mgr@USER
$ sudo systemctl status sdl-mgr@USER
```
## SDL Worker Installation
Listen to UDP 10101 for beacon messages from SDL Manager.
```
nc -u 127.0.0.1 10101
```
> NOTE: CTRL-C to exit `nc`. It cannot run on port 10101 in parallel with the sdl-wkr.
Run the **curl** or **wget** command provided by SDL Manager UDP beacon to install SDL Worker locally.
> sdl-wkr is installed to $HOME/.sdl/sdl-wkr
sdl-wrk will be:
- installed to $HOME/.sdl/sdl-wkr
- run as a non-root user (e.g., `sdl-wkr@USER`)
- sets up USER based systemd service
- get's configuration from the sdl-mgr UDP beacon
### System D Process
> sdl-mgr runs as a non-root user (e.g., `sdl-mgr@USER`)
```
$ sudo systemctl start sdl-wkr@USER
$ sudo systemctl status sdl-wkr@USER
```

32
changelog.json Normal file
View File

@ -0,0 +1,32 @@
{
"releases": [
{
"version": "0.2.12",
"date": "2026-01-31",
"maturity": "ALPHA",
"summary": "Adding Change Log",
"notes": ""
},
{
"version": "0.2.11",
"date": "2026-01-31",
"maturity": "ALPHA",
"summary": "Refining Developer Workflow",
"notes": ""
},
{
"version": "0.2.10",
"date": "2026-01-30",
"maturity": "ALPHA",
"summary": "Fixing dynamic IPs detection",
"notes": "Suports DHCP address changes"
},
{
"version": "0.2.9",
"date": "2026-01-30",
"maturity": "ALPHA",
"summary": "sdl-wkr install, UDP Beacon udpates",
"notes": ""
}
]
}

25
config.json Normal file
View File

@ -0,0 +1,25 @@
{
"DIRS": {
"SDL_HOME": "~/.sdl",
"SDL_CONF": "SDL_HOME/conf",
"SDL_LOGS": "SDL_HOME/logs",
"SDL_DIST": "SDL_HOME/dist",
"SDL_DATA": "SDL_HOME/data",
"SDL_PROJ": "SDL_DATA/proj",
"SDL_JOBS": "SDL_DATA/jobs",
"SDL_MGR": "SDL_HOME/sdl-mgr",
"SDL_WKR": "SDL_HOME/sdl-wkr",
"SDL_NODEJS": "SDL_HOME/nodejs"
},
"NODEJS": {
"VERSION": "22.22.0",
"TARGETS": [
"linux:x64",
"linux:arm64",
"linux:armv7l",
"darwin:x64",
"darwin:arm64",
"win:x64"
]
}
}

69
docs/CHANGELOG.md Normal file
View File

@ -0,0 +1,69 @@
# Software Defined Laboratory (SDL)
| Attribute | Value |
| --- | --- |
| **Author** | John Haverlack |
| **Copyright** | 2026 John Haverlack |
| **License** | MIT |
| **Version** | 0.2.12 |
| **Date** | 2026-01-31 |
## v0.2.12 - 2026-01-31 (ALPHA)
**Summary**
Adding Change Log
- fixing CSS Bug
- Adding Changelog to WebUI
- Fixed CHANGELOG generator
- Start v0.2.12
## v0.2.11 - 2026-01-31 (ALPHA)
**Summary**
Refining Developer Workflow
- Fixing new tag
- v0.2.11
- v0.2.11
- FIXING ChangeLog Generator
- Manual changelog.json updates
- Adding WORKFLOW.md
- Branch Merge Clean up
- Removing old tools
- CP
- Fixed ./tools/genmd-changelog.sh
- WIP: CHANGELOG.me
- WIP: Code Cleaning
- WIP CP changelog-upodate.sh
- WIP changelog update
- WIP: changelog update
- WIP: Changelog
- adding uptime
- Fixing Footer
- Updated Web page and repo
- Init: v0.2.11
## v0.2.10 - 2026-01-30 (ALPHA)
**Summary**
Fixing dynamic IPs detection
**Notes**
Suports DHCP address changes
- v0.2.10
- FIXED: Dynmapic IP Addr Detection
- Starting v0.2.10
## v0.2.9 - 2026-01-30 (ALPHA)
**Summary**
sdl-wkr install, UDP Beacon udpates
- v0.2.9
- Fixing sdl-wkr Install
- Fixing sdl-wkr Install
- Updating Beacon
- v0.2.9: INIT

206
docs/DESIGN.md Normal file
View File

@ -0,0 +1,206 @@
# Software Defined Laboratory (SDL)
| Attribute | Value |
| --- | --- |
| **Author** | John Haverlack |
| **Copyright** | 2026 John Haverlack |
| **License** | MIT |
| **Version** | 0.3.0 |
| **Date** | 2026-01-31 |
# Roadmap
## Status
- [x] sdl-mgr MQTT Broker
- [x] sdl-mgr UDP Beacon
- [x] sdl-mgr Web UI MQTT WebSocket Pub/Sub
- [x] sdl-wkr UDP Listener
## Prioritized Tasks
MVP - Minimum Viable Product Task List
- [x] SDL_ID Node ID
- [x] Version Updater Script
- [x] Deploy sld-mgr to $SDL_HOME/sdl-mgr
- [ ] Create Worker Join Process
- [ ] Create Worker Heartbeat Process
- [x] Create SDL Worker Dist Build Process
- [x] Create SDL Worker Install to $SDL_HOME
- [x] SDL Install Script
- [ ] Data Storage Organizational Structure
- [ ] MinIO S3 Storage Server
- [ ] Worker Heartbeat Hardware Inventory (CPU, RAM, GPU, etc)
- [ ] Create SDL Worker Auto Update Process
- [ ] Worker Heartbeat Load Status (CPU, RAM, GPU, Disk IO, etc)
# 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 Worker Install Script
- Curl
### SDL Worker Auto Update Process
- TBD
### SDL Dist Build Process
```
dist
├── sdl-mgr
├── sdl-wkr
├── nodejs-bin
└── installers
```
### Worker Join
- UDP Bcast
- MQTT Join
### Worker Heartbeat
- MQTT Heartbeat
### 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.
```
PENDING
→ ASSIGNED
→ RUNNING
→ DONE | FAILED | CANCELLED
```

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

415
install-sdl.sh Executable file
View File

@ -0,0 +1,415 @@
#!/usr/bin/env bash
# install-sdl.sh - SDL 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[SDL_HOME]:-}" ]]; then
echo "ERROR: DIRS.SDL_HOME is not defined in config.json"
exit 1
fi
resolved["SDL_HOME"]="${raw[SDL_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" != *SDL_* ]]; 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
}
# ------------------------------------------------------------
# Main
# ------------------------------------------------------------
# ------------------------------------------------------------
# Require non root user
# ------------------------------------------------------------
if [[ $EUID -eq 0 ]]; then
echo "ERROR: install-sdl.sh must be run as a non-root user"
exit 1
fi
# ------------------------------------------------------------
# Requirements
# ------------------------------------------------------------
for cmd in awk curl grep jq sed sha256sum tar unzip; do
command -v "$cmd" >/dev/null || {
echo "ERROR: $cmd is required"
exit 1
}
done
# ------------------------------------------------------------
# Resolve SDL_ROOT
# ------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SDL_ROOT="$(cd "$SCRIPT_DIR" && pwd)"
CONFIG_FILE="$SDL_ROOT/config.json"
META_FILE="$SDL_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
SDL_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
# ------------------------------------------------------------
# systemd user service check
# ------------------------------------------------------------
if ! systemctl --user status >/dev/null 2>&1; then
echo "ERROR: systemd user services are not available"
echo " SDL requires systemd --user support"
exit 1
fi
SDL_VERSION="$(jq -r '.METADATA.version' "$META_FILE")"
SDL_VERSION_DATE="$(jq -r '.METADATA.version_date' "$META_FILE")"
SDL_COPYRIGHT="$(jq -r '.METADATA.copyright' "$META_FILE")"
SDL_AUTHOR="$(jq -r '.METADATA.author' "$META_FILE")"
SDL_LICENSE="$(jq -r '.METADATA.license' "$META_FILE")"
SDL_HOMEPAGE="$(jq -r '.METADATA.homepage' "$META_FILE")"
SDL_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 "Software Defined Laboratory (SDL)"
echo "####################################"
echo " Version: $SDL_VERSION"
echo " Version Date: $SDL_VERSION_DATE"
echo " Copyright: (C) $SDL_COPYRIGHT"
echo " Author: $SDL_AUTHOR"
echo " License: $SDL_LICENSE"
echo " Homepage: $SDL_HOMEPAGE"
echo " Repository: $SDL_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 " SDL_ROOT: $SDL_ROOT"
resolve_dirs "$CONFIG_FILE"
# ------------------------------------------------------------
# Build sdl-mgr tarball to SDL_DIST
# Always rebuild sdl-mgr_version.tgz from source
# ------------------------------------------------------------
echo ""
echo "Generating: $SDL_DIST/sdl-mgr_$SDL_VERSION.tgz"
tar -czf "$SDL_DIST/sdl-mgr_$SDL_VERSION.tgz" -C "$SDL_ROOT" sdl-mgr
sha256sum "$SDL_DIST/sdl-mgr_$SDL_VERSION.tgz" | awk '{sub(".*/","",$2); print}' > "$SDL_DIST/sdl-mgr_$SDL_VERSION.tgz.sha256"
# ------------------------------------------------------------
# Build sdl-wkr tarball to SDL_DIST
# Always rebuild sdl-wkr_version.tgz from source
# ------------------------------------------------------------
echo ""
echo "Generating: $SDL_DIST/sdl-wkr_$SDL_VERSION.tgz"
tar -czf "$SDL_DIST/sdl-wkr_$SDL_VERSION.tgz" -C "$SDL_ROOT" sdl-wkr
sha256sum "$SDL_DIST/sdl-wkr_$SDL_VERSION.tgz" | awk '{sub(".*/","",$2); print}' > "$SDL_DIST/sdl-wkr_$SDL_VERSION.tgz.sha256"
# ------------------------------------------------------------
# Copy install-sdl-wkr.sh to SDL_DIST
# ------------------------------------------------------------
echo ""
echo "Copying: $SDL_DIST/install-sdl-wkr.sh"
cp "$SDL_ROOT/sdl-wkr/scripts/install-sdl-wkr.sh" "$SDL_DIST/install-sdl-wkr.sh"
sha256sum "$SDL_DIST/install-sdl-wkr.sh" | awk '{sub(".*/","",$2); print}' > "$SDL_DIST/install-sdl-wkr.sh.sha256"
# ------------------------------------------------------------
# Fetch NodeJS Tarballs / Zips to SDL_DIST
# ------------------------------------------------------------
echo ""
echo "Fetching NodeJS Binaries to $SDL_DIST"
SHASUM_URL="https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt"
curl -sSL "$SHASUM_URL" -o "$SDL_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 "$SDL_DIST/$NODE_TARBALL" ]; then
curl -sSL "$NODE_URL" -o "$SDL_DIST/$NODE_TARBALL"
EXPECTED_SHA="$(grep " $NODE_TARBALL\$" "$SDL_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 "$SDL_DIST/$NODE_TARBALL" | awk '{print $1}')"
if [[ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]]; then
echo "SHA mismatch $NODE_TARBALL"
rm -f "$SDL_DIST/$NODE_TARBALL"
exit 1
fi
echo "$EXPECTED_SHA $NODE_TARBALL" > "$SDL_DIST/$NODE_TARBALL.sha256"
fi
done
unset OS ARCH
# ------------------------------------------------------------
# Extract sdl-mgr-VERSION tarball from SDL_DIST to SDL_MGR
# Symlink to SDL_MGR/current
# ------------------------------------------------------------
echo ""
echo "Extracting: $SDL_DIST/sdl-mgr_$SDL_VERSION.tgz to $SDL_MGR/sdl-mgr_$SDL_VERSION"
rm -rf "$SDL_MGR/sdl-mgr_$SDL_VERSION"
tar -xzf "$SDL_DIST/sdl-mgr_$SDL_VERSION.tgz" -C "$SDL_MGR"
mv "$SDL_MGR/sdl-mgr" "$SDL_MGR/sdl-mgr_$SDL_VERSION"
# rm "$SDL_MGR/current"
ln -sfn "$SDL_MGR/sdl-mgr_$SDL_VERSION" "$SDL_MGR/current"
# ------------------------------------------------------------
# Copy SDL_MGR/current/conf/efault-sdl-mgr-config.json to SDL_CONF/sdl-mgr-config.json
# ------------------------------------------------------------
if [ ! -e "$SDL_CONF/sdl-mgr-config.json" ]; then
echo ""
echo "Copying: $SDL_MGR/current/conf/default-sdl-mgr-config.json to $SDL_CONF/sdl-mgr-config.json"
cp "$SDL_MGR/current/conf/default-sdl-mgr-config.json" "$SDL_CONF/sdl-mgr-config.json"
fi
# ------------------------------------------------------------
# Extract NodeJS tarball from SDL_DIST to SDL_NODEJS
# Symlink to SDL_NODEJS/current
# ------------------------------------------------------------
# Check if NodeJS is already installed
NODE_BIN="$SDL_NODEJS/current/bin/node"
NPM_BIN="$SDL_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: $SDL_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 "$SDL_DIST/node-v${NODE_VERSION}-${OS_PLATFORM}-${OS_ARCH}.$EXT" -d "$SDL_NODEJS"
else
tar $TAR_OPTS "$SDL_DIST/node-v${NODE_VERSION}-${OS_PLATFORM}-${OS_ARCH}.$EXT" -C "$SDL_NODEJS"
fi
# rm "$SDL_NODEJS/current"
ln -sfn "$SDL_NODEJS/node-v${NODE_VERSION}-${OS_PLATFORM}-${OS_ARCH}" "$SDL_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: $SDL_DIST/node-v${NODE_VERSION}-${OS_PLATFORM}-${OS_ARCH}.tar.xz"
tar -xJf "$SDL_DIST/node-v${NODE_VERSION}-${OS_PLATFORM}-${OS_ARCH}.tar.xz" -C "$SDL_NODEJS"
ln -sfn "$SDL_NODEJS/node-v${NODE_VERSION}-${OS_PLATFORM}-${OS_ARCH}" "$SDL_NODEJS/current"
fi
# ------------------------------------------------------------
# Install sdl-mgr Node Dependencies
# ------------------------------------------------------------
echo ""
echo "Installing NodeJS Dependencies"
cd "$SDL_MGR/current/app"
export PATH="$SDL_NODEJS/current/bin:$PATH"
$NPM_BIN install
# ------------------------------------------------------------
# Install Systemd Unit Files
# Copy SDL_MGR/current/scripts/sdl-mgr.service to ~/.config/systemd/user
# ------------------------------------------------------------
echo ""
echo "Installing SystemD Unit Files"
mkdir -p ~/.config/systemd/user
cp "$SDL_MGR/current/scripts/sdl-mgr.service" ~/.config/systemd/user/
systemctl --user daemon-reload --no-pager
systemctl --user restart sdl-mgr --no-pager
sleep 3
systemctl --user status sdl-mgr --no-pager
echo ""
echo "SDL Manager deployed:"
echo " to: $SDL_HOME"
echo "SDL Controls:"
echo " systemctl --user status sdl-mgr"
echo " systemctl --user start sdl-mgr"
echo " systemctl --user stop sdl-mgr"
echo ""
echo "Removal Commands:"
echo " WARNING: Backup data directory first!!!!"
echo " systemctl --user disable sdl-mgr"
echo " rm -rf $SDL_HOME"

41
metadata.json Normal file
View File

@ -0,0 +1,41 @@
{
"METADATA": {
"name": "sdl",
"description": "Software Defined Laboratory",
"abbr": "SDL",
"version": "0.3.0",
"version_date": "2026-01-31",
"maturity": "ALPHA",
"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
sdl-mgr/.gitignore vendored Normal file
View File

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

9
sdl-mgr/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
sdl-mgr/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
sdl-mgr/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,93 @@
const module = 'mqtt'; // Module Name
import { load_config, log } from '../nwa-lib/index.js';
const config = load_config();
log(`Loaded module: ${module}`);
// log(`${module}: JSON.stringify(config.modules[module]): ${JSON.stringify(config.modules[module], null, 2)}`);
import aedes from 'aedes';
import net from 'net';
import http from 'http';
import websocketStream from 'websocket-stream';
const broker = aedes();
const port = {
"mqtt": config.modules[module].mqtt_port,
"ws": config.modules[module].ws_port
}
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'];
if (!Number.isInteger(port.mqtt) || !Number.isInteger(port.ws)) {
throw new Error(`${module}: invalid port configuration`);
}
// ---- TCP MQTT (1883) ----
for (const addr of bindAddrs) {
const tcpServer = net.createServer(broker.handle);
tcpServer.listen(port.mqtt, addr, () => {
// log(`${module}: INFO: MQTT TCP broker listening on ${addr}:${port.mqtt}`, true);
});
}
// IP's for TCP MQTT
for (const addr of bindAddrs) {
if (addr === '0.0.0.0') {
for (const ip of config.host.ips) {
log(`${module}: INFO: MQTT TCP broker listening on ${ip}:${port.mqtt}`, true);
}
} else {
log(`${module}: INFO: MQTT TCP broker listening on ${addr}:${port.mqtt}`, true);
}
}
// ---- WebSocket MQTT (9001) ----
// ---- WebSocket MQTT (9001) ----
for (const addr of bindAddrs) {
const httpServer = http.createServer();
websocketStream.createServer(
{ server: httpServer },
broker.handle
);
httpServer.listen(port.ws, addr, () => {
if (addr === '0.0.0.0') {
for (const ip of config.host.ips) {
log(`${module}: INFO: MQTT WS broker listening on ${ip}:${port.ws}`, true);
}
} else {
log(`${module}: INFO: MQTT WS broker listening on ${addr}:${port.ws}`, true);
}
});
}
// ---- Logging (optional but useful) ----
broker.on('client', (client) => {
// console.log('Client connected:', client?.id);
log(`${module}: Client connected: ${client?.id}`);
});
broker.on('clientDisconnect', (client) => {
// console.log('Client disconnected:', client?.id);
log(`${module}: Client disconnected: ${client?.id}`);
});
broker.on('publish', (packet, client) => {
if (client) {
// console.log(
// `Publish from ${client.id}: ${packet.topic}`
// );
log(`${module}: Publish from ${client.id}: ${packet.topic}`);
}
});

View File

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

View File

@ -0,0 +1,602 @@
// 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 sdl_home = path.join(__dirname, '..', '..', '..', '..', '..'); // SDL_HOME Default
let sdl_mgr_cfg_file = path.join(sdl_home, 'conf', 'sdl-mgr-config.json');
// console.log("DEBUG: SDL_HOME: " + sdl_home);
// console.log("DEBUG: SDL_MGR_CFG_FILE: " + sdl_mgr_cfg_file);
try {
fs.accessSync(sdl_mgr_cfg_file, fs.constants.R_OK);
} catch (err) {
console.error(err);
process.exit(1);
}
// // Load from ENV
// if (process.env.SDL_HOME) {
// sdl_home = process.env.SDL_HOME;
// sdl_mgr_cfg_file = path.join(sdl_home, 'conf', 'sdl-mgr-config.json');
// // console.log("DEBUG3: SDL_HOME: " + sdl_home);
// // console.log("DEBUG3: SDL_MGR_CFG_FILE: " + sdl_mgr_cfg_file);
// try {
// fs.accessSync(sdl_mgr_cfg_file, fs.constants.R_OK);
// } catch (err) {
// console.error(err);
// process.exit(1);
// }
// }
// // Load from ARGV
// if (argv.configfile) {
// sdl_home = path.dirname(argv.configfile);
// sdl_mgr_cfg_file = path.join(sdl_home, 'conf', 'sdl-mgr-config.json');
// // console.log("DEBUG4: SDL_HOME: " + sdl_home);
// // console.log("DEBUG4: SDL_MGR_CFG_FILE: " + sdl_mgr_cfg_file);
// try {
// fs.accessSync(sdl_mgr_cfg_file, fs.constants.R_OK);
// } catch (err) {
// console.error(err);
// process.exit(1);
// }
// }
// console.log("DEBUG: SDL_HOME: " + sdl_home);
// console.log("DEBUG: SDL_MGR_CFG_FILE: " + sdl_mgr_cfg_file);
// Load Config
try {
config = JSON.parse(fs.readFileSync(sdl_mgr_cfg_file, 'utf8'));
config.dirs.base = config.dirs.base + '/..'
} catch (err) {
console.error(err);
}
// console.log("DEBUG: sdl_home: " + sdl_home);
// Expand Data Directory
let search_replace = {}
search_replace['DIRNAME'] = __dirname
// search_replace['SDLHOME'] = sdl_home
config.dirs['SDLHOME'] = sdl_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 = {}
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: {
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.identity = {
sdl_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;
}
// -------------------------------
// 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
}
}
// -------------------------------
// SDL Identity
// -------------------------------
try {
const idinfo = load_or_create_sdl_id(config.dirs.sdlhome);
config.identity.sdl_id = idinfo.sdl_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_sdl_id(sdl_home) {
const id_dir = path.join(sdl_home, 'conf');
const id_file = path.join(id_dir, 'sdl-id.json');
try {
if (fs.existsSync(id_file)) {
const data = JSON.parse(fs.readFileSync(id_file, 'utf8'));
if (data.sdl_id) {
return data;
}
}
} catch (err) {
console.error('Failed to read sdl-id.json:', err);
process.exit(1);
}
// Create new identity
const sdl_id = {
sdl_id: crypto.randomUUID(),
created_at: new Date().toISOString(),
hostname: os.hostname()
};
try {
fs.mkdirSync(id_dir, { recursive: true });
fs.writeFileSync(id_file, JSON.stringify(sdl_id, null, 2), { mode: 0o600 });
} catch (err) {
console.error('Failed to write sdl-id.json:', err);
process.exit(1);
}
return sdl_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}.${h}.${m}.${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,232 @@
const module = 'sdl-mgr'; // Module Name
import { load_config, log, ipToInt, intToIp, computeBroadcast, getUptimeDHMS } from '../nwa-lib/index.js';
import mqtt from 'mqtt';
import os from 'os';
import dgram from 'dgram';
const config = load_config();
log(`Loaded module: ${module}`);
// log(`${module}: Cluster Conf: ${JSON.stringify(config.cluster, null, 2)}`, true);
function startSDLStatusPub() {
const sdlCfg = config.modules[module];
const mqttCfg = config.modules.mqtt;
if (!sdlCfg?.enabled) {
log(`${module}: disabled, not publishing status`);
return;
}
if (!mqttCfg?.enabled) {
log(`${module}: MQTT disabled, cannot publish status`);
return;
}
const statusInterval =
Number.isInteger(sdlCfg.update_interval.cluster_status) &&
sdlCfg.update_interval.cluster_status > 0
? sdlCfg.update_interval.cluster_status
: 10000;
// config.modules.mqtt.topics.sdl-mgr.pub.sdl_cluster-status
const statusTopic = mqttCfg.topics?.[module]?.pub?.['sdl_cluster-status'];
if (!statusTopic) {
log(`${module}: ${statusTopic} not configured`);
return;
}
// This is ok because we are running on localhost
const mqttUrl = `mqtt://127.0.0.1:${mqttCfg.mqtt_port}`;
const client = mqtt.connect(mqttUrl);
client.on('connect', () => {
log(`${module}: connected to MQTT at ${mqttUrl}`);
publishStatus(client, statusTopic);
setInterval(() => publishStatus(client, statusTopic), statusInterval);
});
client.on('error', err => {
log(`${module}: MQTT error: ${err}`);
});
}
function publishStatus(client, topic) {
const status = {
ts: new Date().toISOString(),
sdl_id: config.identity.sdl_id,
role: 'sdl-mgr',
host: config.identity.hostname,
type: 'cluster-status',
msg: {
sdl: {
version: config.package.version,
uptime: getUptimeDHMS()
},
cluster: config.cluster,
modules: Object.fromEntries(
Object.entries(config.modules).map(([name, mod]) => [
name,
{ enabled: mod?.enabled === true }
])
)
}
};
client.publish(
topic,
JSON.stringify(status),
{ qos: 1, retain: true },
err => {
if (err) {
log(`${module}: failed to publish status: ${err}`);
} else {
log(`${module}: published status to ${topic}`);
}
}
);
}
function startUdpBeacon() {
let sdlCfg = config.modules['sdl-mgr'];
// log(`${module}: DEBUG: SDL config: ${JSON.stringify(sdlCfg, null, 2)}`, true);
// log(`${module}: DEBUG: Cluster Conf: ${JSON.stringify(config.cluster, null, 2)}`, true);
sdlCfg.cluster = config.cluster;
const mqttCfg = config.modules.mqtt;
if (!sdlCfg?.enabled) {
log(`${module}: sdl disabled, not starting UDP beacon`);
return;
}
if (!mqttCfg?.enabled) {
log(`${module}: mqtt disabled, cannot advertise broker`);
return;
}
const beaconPort =
Number.isInteger(sdlCfg.beacon_udp_port)
? sdlCfg.beacon_udp_port
: 10101;
const beaconInterval =
Number.isInteger(sdlCfg.update_interval.udp_beacon)
? sdlCfg.update_interval.udp_beacon
: 2000;
const cluster = sdlCfg.cluster || {
id: 'default',
name: 'default',
desc: ''
};
log(`${module}: DEBUG: cluster: ${JSON.stringify(cluster)}`);
if (!config.host?.network) {
log(`${module}: no host network metadata available for UDP beacon`);
return;
}
const socket = dgram.createSocket('udp4');
socket.on('error', err => {
log(`${module}: UDP beacon socket error: ${err}`);
});
socket.bind(() => {
socket.setBroadcast(true);
log(`${module}: UDP beacon active on port ${beaconPort}`);
});
setInterval(() => {
const interfaces = os.networkInterfaces();
// for (const [iface, addrs] of Object.entries(config.host.network)) {
for (const [iface, addrs] of Object.entries(interfaces)) {
for (const addr of addrs) {
// --- FILTERS ---
// IPv4 only
if (addr.family !== 'IPv4') continue;
// Skip loopback
if (addr.internal === true) continue;
// Skip /32 networks (no broadcast: e.g. Tailscale)
const cidr = Number(addr.cidr?.split('/')[1]);
if (!Number.isInteger(cidr) || cidr >= 32) continue;
// Compute broadcast address
const broadcastAddr = computeBroadcast(addr.address, addr.netmask);
if (!broadcastAddr) continue;
const beacon = {
ts: new Date().toISOString(),
sdl_id: config.identity.sdl_id,
role: 'sdl-mgr',
host: config.identity.hostname,
type: 'udp-beacon',
msg: {
info: {
desc: config.package.description,
version: config.package.version,
copyright: config.package.copyright
},
cluster: {
id: cluster.id,
name: cluster.name,
desc: cluster.desc
},
mqtt: {
host: addr.address,
port: mqttCfg.mqtt_port
},
web: {
proto: 'http',
host: addr.address,
port: config.modules.web.port,
api_config: '/api/config',
config_url: `http://${addr.address}:${config.modules.web.port}/api/config`
},
sdl_wkr_install_cmd: {
curl: `curl -s http://${addr.address}:${config.modules.web.port}/dist/install-sdl-wkr.sh | bash -s ${addr.address}`,
wget: `wget -O - http://${addr.address}:${config.modules.web.port}/dist/install-sdl-wkr.sh | bash -s ${addr.address}`
}
}
};
const payload = Buffer.from(JSON.stringify(beacon)+"\n");
socket.send(
payload,
0,
payload.length,
beaconPort,
broadcastAddr,
err => {
if (err) {
log(`${module}: UDP beacon send error (${broadcastAddr}): ${err}`);
}
}
);
}
}
}, beaconInterval);
log(
`${module}: broadcasting UDP beacon every ${beaconInterval}ms on port ${beaconPort}`
);
}
// -------------------------------
// Entry point (ESM-safe)
// -------------------------------
startSDLStatusPub();
startUdpBeacon();

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,207 @@
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))
});
// Distribution
app.use(
'/dist',
logStaticRequests('dist'),
express.static(config.dirs.dist),
serveIndex(config.dirs.dist, {
icons: true,
view: 'details'
})
);
// /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"
}

46
sdl-mgr/app/package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "sdl-mgr",
"version": "0.3.0",
"version_date": "2026-01-31",
"description": "Software Defined Laboratory: Manager (sdl-mgr)",
"main": "index.js",
"type": "module",
"scripts": {
"start": "node index.js"
},
"repository": {
"type": "git",
"url": "https://github.com/jehaverlack/sdl.git"
},
"author": "John Haverlack",
"copyright": "2026 John Haverlack",
"license": "MIT",
"license_url": "https://opensource.org/license/MIT",
"bugs": {
"url": ""
},
"homepage": "https://github.com/jehaverlack/sdl",
"dependencies": {
"@alenaksu/json-viewer": "^2.0.1",
"@fortawesome/fontawesome-free": "^6.7.2",
"aedes": "^0.51.3",
"bootstrap": "^5.3.7",
"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,26 @@
{
"cluster": {
"id": "haverlab",
"name": "Haverlab",
"desc": "John Haverlack's Home Lab"
},
"dirs": {
"base": "DIRNAME/../..",
"sdlhome": "SDLHOME",
"app": "BASE/app",
"modules": "APP/modules",
"dist": "SDLHOME/dist",
"logs": "SDLHOME/logs",
"data": "SDLHOME/data",
"proj": "DATA/proj",
"jobs": "DATA/jobs",
"conf": "SDLHOME/conf",
"modconf": "BASE/conf/modules",
"html": "BASE/html"
},
"modules": {
"mqtt": {},
"web": {},
"sdl-mgr": {}
}
}

View File

@ -0,0 +1,37 @@
{
"enabled": true,
"bind_ip_addrs": ["0.0.0.0"],
"mqtt_port": 1883,
"ws_port": 9001,
"topics":{
"sdl-mgr":{
"pub": {
"sdl_join-auth": "sdl/join/auth",
"sdl_cluster-status": "sdl/cluster/status",
"sdl_cluster-workers": "sdl/cluster/workers"
},
"sub": {
"sdl_join-req": "sdl/join/req",
"sdl_cluster-telemetry": "sdl/cluster/telemetry"
}
},
"sdl-wkr":{
"pub": {
"sdl_join-req": "sdl/join/req",
"sdl_cluster-telemetry": "sdl/cluster/telemetry"
},
"sub": {
"sdl_join-auth": "sdl/join/auth",
"sdl_cluster-status": "sdl/cluster/status"
}
},
"sdl-web": {
"pub": {
},
"sub": {
"sdl_cluster-status": "sdl/cluster/status",
"sdl_cluster-workers": "sdl/cluster/workers"
}
}
}
}

View File

@ -0,0 +1,14 @@
{
"enabled": true,
"beacon_udp_port":10101,
"update_interval": {
"udp_beacon": 2000,
"cluster_status": 10000,
"cluster_workers": 4000,
"cluster_telemetry": 3000
},
"expiration_timeout": {
"cluster_status": 60000,
"cluster_workers": 60000
}
}

View File

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

View File

@ -0,0 +1,20 @@
{
"enabled": true,
"bind_ip_addrs": ["0.0.0.0"],
"port": 8081,
"open_browser": false,
"weblibs": {
"bootstrap":"bootstrap/dist",
"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"
}
}

13
sdl-mgr/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
sdl-mgr/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": true,
"items": []
},
"wkrs": {
"title": "Workers",
"desc": "Workers",
"icon": "fa-solid fa-server",
"page": "wkrs",
"enabled": true,
"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": true
}
]
},
"jobs": {
"title": "Jobs",
"desc": "Jobs",
"icon": "fa-solid fa-person-digging",
"page": "jobs",
"enabled": true,
"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
}
]
}
}

View File

@ -0,0 +1,43 @@
[
{
"title": "SD Lab",
"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": "API",
"icon": "fa-solid fa-code",
"page": "api",
"enabled": true
}
]
}
]

View File

@ -0,0 +1,214 @@
/* ------------------------------
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%);
}

36
sdl-mgr/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
sdl-mgr/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

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

89
sdl-mgr/html/index.html Normal file
View File

@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="en">
<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">
<!-- 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="bg-light">
<!-- 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
sdl-mgr/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
sdl-mgr/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`;
}

397
sdl-mgr/html/js/dash.js Normal file
View File

@ -0,0 +1,397 @@
// ===============================
// SDL Dashboard MQTT Client
// ===============================
import { getConfig } from './weblib.js';
const config = await getConfig();
// console.log('config:', JSON.stringify(config, null, 2));
let mqttClient = null;
let mqttConfig = null;
let sdlConfig = null;
let lastSdlStatus = null;
let lastSdlTimestamp = null;
let statusCheckTimer = null;
const STALE_GRACE_MS = 2000;
// -------------------------------
// GPU Summary (for dashboard)
// -------------------------------
let gpuSummary = 'None';
if (Array.isArray(config.host.gpu) && config.host.gpu.length > 0) {
gpuSummary = config.host.gpu.map(gpu => {
const displayName =
gpu.vendor === 'nvidia'
? `NVIDIA ${shortGpuName(gpu)}`
: `Intel ${shortGpuName(gpu)}`;
const fullName = gpu.model;
let memLabel = '';
// if (gpu.memory?.type === 'dedicated' && gpu.memory.total_mb) {
if (
gpu.memory?.type === 'dedicated' &&
Number.isFinite(gpu.memory.total_mb)
) {
memLabel = `(<span class="text-success">${Math.round(gpu.memory.total_mb / 1024)}</span> GB VRAM)`;
} else if (gpu.memory?.type === 'shared') {
memLabel = '(shared VRAM)';
} else {
console.log('ERR: gpu.memory:', JSON.stringify(gpu.memory));
}
return `
<span
style="font-size:0.75em; cursor:help"
title="${fullName}">
${displayName} ${memLabel}
</span>
`;
}).join('<br>');
}
// Hostinfo: hostname, cpu, cores, ram, os
const hostinfo = {
"hostname": '<span class="text-success">' + config.host.hostname + '</span>',
"sdl_id": '<span class="text-success" style=" font-size:0.7em">' + config.identity.sdl_id + '</span>',
"cpu cores": '<span class="text-success">' + config.host.cpu.cores_logical + '</span> <span style="font-size:0.7em">' + config.host.cpu.model + '</span>',
// "cores": config.host.cpu.cores_logical,
"ram": '<span class="text-success">' + config.host.memory.total_gb + '</span> GB ',
"gpu": gpuSummary,
"net": "",
"os": config.host.os.pretty_name
}
for (let intf in config.host.network) {
// console.log(intf, JSON.stringify(config.host.network[intf], null, 2));
if (intf != 'lo') {
for (let addr in config.host.network[intf]) {
if (config.host.network[intf][addr].family == 'IPv4') {
hostinfo["net"] += '<span class="text-success" style=" font-size: 0.8em">' + config.host.network[intf][addr].cidr + '</span><br> ';
}
}
}
}
let host_html = '<h5>SDL Manager Host</h5>\n';
host_html += '<table class="table table-sm table-striped">\n';
// host_html += '<thead>\n';
// host_html += '<tr>\n';
// host_html += '<th>Key</th>\n';
// host_html += '<th>Value</th>\n';
// host_html += '</tr>\n';
// host_html += '</thead>\n';
host_html += '<tbody>\n';
for (let key in hostinfo) {
host_html += '<tr>\n';
host_html += '<td><b>' + key + '</b></td>\n';
host_html += '<td>' + hostinfo[key] + '</td>\n';
host_html += '</tr>\n';
}
host_html += '</tbody>\n';
host_html += '</table>\n';
document.getElementById('dash-sdl-mgr-info').innerHTML = host_html;
initMqtt();
// function shortGpuName(gpu) {
// let name = gpu.model;
// // Strip vendor boilerplate
// name = name
// .replace(/^NVIDIA Corporation\s+/i, '')
// .replace(/^Intel Corporation\s+/i, '');
// // NVIDIA cleanup
// if (gpu.vendor === 'nvidia') {
// name = name.replace(/^GeForce\s+/i, 'RTX ');
// }
// // Intel cleanup
// if (gpu.vendor === 'intel') {
// name = name.replace(/\s*\[.*?\]\s*/g, '');
// }
// return name.trim();
// }
function shortGpuName(gpu) {
const full = gpu.model;
// NVIDIA: extract RTX/GTX number from brackets
if (gpu.vendor === 'nvidia') {
const m = full.match(/\[(?:GeForce\s+)?(RTX|GTX)\s+(\d+)/i);
if (m) {
return `${m[1].toUpperCase()} ${m[2]}`;
}
// fallback
return 'NVIDIA GPU';
}
// Intel: strip bracketed suffix, keep platform
if (gpu.vendor === 'intel') {
return full
.replace(/^Intel Corporation\s+/i, '')
.replace(/\s*\[.*?\]\s*/g, '')
.trim();
}
return full;
}
async function initMqtt() {
// 1) Load config (no cache must be authoritative)
// const res = await fetch('/api/config', { cache: 'no-store' });
// const config = await res.json();
mqttConfig = config.modules?.mqtt;
sdlConfig = config.modules?.['sdl-mgr'];
// console.log('config:', JSON.stringify(config.modules, null, 2));
// console.log('MQTT config:', JSON.stringify(mqttConfig, null, 2));
// console.log('SDL config:', JSON.stringify(sdlConfig, null, 2));
// 2) Hard requirement: embedded MQTT must be enabled
if (!mqttConfig) {
throw new Error('MQTT module not defined in config');
}
if (mqttConfig.enabled !== true) {
throw new Error('MQTT module is disabled; dashboard requires MQTT');
}
if (!mqttConfig.ws_port) {
throw new Error('MQTT ws_port not configured');
}
if (!mqttConfig.topics?.['sdl-web']?.sub?.['sdl_cluster-status']) {
throw new Error('MQTT topic sdl_status not configured');
}
// console.log('MQTT config:', JSON.stringify(mqttConfig, null, 2));
// console.log('SDL config:', JSON.stringify(sdlConfig, null, 2));
// 3) Derive broker WS URL from page origin
const wsProto =
window.location.protocol === 'https:' ? 'wss' : 'ws';
const wsHost = window.location.hostname;
// const wsHost =
// mqttConfig.ws_host
// ?? config.host.ips.find(ip => ip !== '127.0.0.1')
// ?? window.location.hostname;
const wsPort = mqttConfig.ws_port;
const wsUrl = `${wsProto}://${wsHost}:${wsPort}`;
console.log('Connecting to embedded MQTT broker:', wsUrl);
// 4) Connect (IMPORTANT: force path '/')
mqttClient = mqtt.connect(wsUrl, {
path: '/',
reconnectPeriod: 2000,
connectTimeout: 5000
});
// 5) Register handlers
mqttClient.on('connect', () => {
console.log('MQTT connected (dashboard)');
const topic = mqttConfig.topics['sdl-web'].sub['sdl_cluster-status'];
console.log('Subscribing to:', topic);
mqttClient.subscribe(topic, { qos: 1 }, err => {
if (err) {
console.error('MQTT subscribe error:', err);
} else {
console.log('Subscribed to', topic);
}
});
startStatusAgeMonitor();
});
mqttClient.on('message', (topic, payload, packet) => {
try {
const text = new TextDecoder().decode(payload);
const msg = JSON.parse(text);
console.log(
'MQTT RX:',
topic,
msg,
packet.retain ? 'retained' : 'live'
);
if (msg.type === 'cluster-status') {
lastSdlStatus = msg;
lastSdlTimestamp = Date.parse(msg.ts);
renderModulesTable(msg);
}
} catch (e) {
console.warn(
'MQTT parse error:',
e,
payload
);
}
});
mqttClient.on('error', err => {
console.error('MQTT error:', err);
});
mqttClient.on('close', () => {
console.warn('MQTT connection closed');
});
mqttClient.on('reconnect', () => {
console.warn('MQTT reconnecting...');
});
}
function renderModulesTable(msg) {
// console.log('renderModulesTable()');
// console.log(JSON.stringify(msg, null, 2));
let sdl_html = '<h5>Cluster Info</h5>';
sdl_html += '<table class="table table-sm table-striped">';
sdl_html += `<tbody> `;
sdl_html += `
<tr>
<th>SDL Version</th>
<td class="text-success" style="font-size:1em">v${msg.msg.sdl.version}</td>
</tr>
`;
sdl_html += `
<tr>
<th>Uptime (d.h.m.s)</th>
<td class="text-success" style=" font-size:0.8em">${msg.msg.sdl.uptime}</td>
</tr>
`;
sdl_html += `
<tr>
<th>Cluster ID</th>
<td class="text-success" style=" font-size:1em">${msg.msg.cluster.id}</td>
</tr>
`;
sdl_html += `
<tr>
<th>Cluster Name</th>
<td class="text-success" style=" font-size:1em">${msg.msg.cluster.name}</td>
</tr>
`;
sdl_html += `
<tr>
<th>Cluster Desc</th>
<td class="text-success" style=" font-size:0.8em">${msg.msg.cluster.desc}</td>
</tr>
`;
sdl_html += `
</tbody>
</table>
`;
document.getElementById('dash-sdl-info').innerHTML = sdl_html;
const modules = msg.msg.modules;
let html = '<h5>Service Modules</h5>';
html += '<table class="table table-sm table-striped">';
// <thead>
// <tr>
// <th>Module</th>
// <th>Status</th>
// </tr>
// </thead>
html += `<tbody> `;
for (const [name, info] of Object.entries(modules)) {
const ok = info.enabled === true;
const icon = ok
? '<i class="fa-solid fa-circle-check text-success"></i>'
: '<i class="fa-solid fa-circle-xmark text-danger"></i>';
html += `
<tr>
<th${name}</th>
<td>${icon}</td>
</tr>
`;
}
html += `
</tbody>
</table>
`;
document.getElementById('dash-modules-list').innerHTML = html;
}
function startStatusAgeMonitor() {
if (!sdlConfig?.update_interval.cluster_status) return;
const maxAgeMs =
sdlConfig.update_interval.cluster_status + STALE_GRACE_MS;
if (statusCheckTimer) {
clearInterval(statusCheckTimer);
}
statusCheckTimer = setInterval(() => {
if (!lastSdlTimestamp) return;
const ageMs = Date.now() - lastSdlTimestamp;
const ageEl = document.getElementById('dash-modules-age');
if (ageMs > maxAgeMs) {
ageEl.innerHTML =
`<span class="text-danger">
<i class="fa-solid fa-triangle-exclamation"></i>
SDL status stale (${Math.round(ageMs / 1000)}s)
</span>`;
markAllModulesDown();
} else {
ageEl.innerHTML =
`<span class="text-success">
<i class="fa-solid fa-circle-check"></i>
SDL online (${Math.round(ageMs / 1000)}s ago)
</span>`;
}
}, 1000);
}
function markAllModulesDown() {
if (!lastSdlStatus?.modules) return;
const downModules = {};
downModules.msg = {}
for (const name of Object.keys(lastSdlStatus.modules)) {
downModules.msg[name] = { enabled: false };
}
renderModulesTable(downModules);
}

0
sdl-mgr/html/js/docs.js Normal file
View File

0
sdl-mgr/html/js/expr.js Normal file
View File

1
sdl-mgr/html/js/index.js Normal file
View File

@ -0,0 +1 @@

0
sdl-mgr/html/js/jobs.js Normal file
View File

0
sdl-mgr/html/js/proj.js Normal file
View File

864
sdl-mgr/html/js/weblib.js Normal file
View File

@ -0,0 +1,864 @@
// 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>`;
} else {
toggleBtn.innerHTML = `<i class="fa-solid fa-folder-minus"></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>
`;
};
}
// -------------------------------
// 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="bg-light 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="text-dark">\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="text-dark">\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="text-dark">${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="bg-light 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 () => {
const config = await loadConfig();
// console.log(`DEBUG: ${JSON.stringify(config)}`)
// const tags = await loadTags();
// console.log(`DEBUG: ${JSON.stringify(tags)}`)
// Load fragments and layout
includeFragment('banner', '/banner.html');
// includeFragment('footer', '/footer.html');
loadNav();
loadSidePanel();
loadFooter();
// Load Markdown content
const urlParams = new URLSearchParams(window.location.search);
const page = urlParams.get('page') || 'index';
loadMarkdown(`${page}`);
// Update title and app description
if (config?.package?.description) {
document.title = config.package.description;
const descEl = document.querySelector('.app-description');
if (descEl) descEl.innerHTML = config.package.description;
}
});

0
sdl-mgr/html/js/wkrs.js Normal file
View File

22
sdl-mgr/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
sdl-mgr/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>

View File

@ -0,0 +1,69 @@
# Software Defined Laboratory (SDL)
| Attribute | Value |
| --- | --- |
| **Author** | John Haverlack |
| **Copyright** | 2026 John Haverlack |
| **License** | MIT |
| **Version** | 0.2.12 |
| **Date** | 2026-01-31 |
## v0.2.12 - 2026-01-31 (ALPHA)
**Summary**
Adding Change Log
- fixing CSS Bug
- Adding Changelog to WebUI
- Fixed CHANGELOG generator
- Start v0.2.12
## v0.2.11 - 2026-01-31 (ALPHA)
**Summary**
Refining Developer Workflow
- Fixing new tag
- v0.2.11
- v0.2.11
- FIXING ChangeLog Generator
- Manual changelog.json updates
- Adding WORKFLOW.md
- Branch Merge Clean up
- Removing old tools
- CP
- Fixed ./tools/genmd-changelog.sh
- WIP: CHANGELOG.me
- WIP: Code Cleaning
- WIP CP changelog-upodate.sh
- WIP changelog update
- WIP: changelog update
- WIP: Changelog
- adding uptime
- Fixing Footer
- Updated Web page and repo
- Init: v0.2.11
## v0.2.10 - 2026-01-30 (ALPHA)
**Summary**
Fixing dynamic IPs detection
**Notes**
Suports DHCP address changes
- v0.2.10
- FIXED: Dynmapic IP Addr Detection
- Starting v0.2.10
## v0.2.9 - 2026-01-30 (ALPHA)
**Summary**
sdl-wkr install, UDP Beacon udpates
- v0.2.9
- Fixing sdl-wkr Install
- Fixing sdl-wkr Install
- Updating Beacon
- v0.2.9: INIT

10
sdl-mgr/html/md/dash.md Normal file
View File

@ -0,0 +1,10 @@
<div class="row">
<div class="col-3" id="dash-sdl-info">
</div>
<div class="col-4" id="dash-sdl-mgr-info">
</div>
<div class="col-3" id="dash-modules">
<div id="dash-modules-list"></div>
<div id="dash-modules-age"></div>
</div>
</div>

119
sdl-mgr/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 sha256sum 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>
```

0
sdl-mgr/html/md/expr.md Normal file
View File

4
sdl-mgr/html/md/index.md Normal file
View File

@ -0,0 +1,4 @@
A modern parallel compute engine for conducting Computational Experiments.

0
sdl-mgr/html/md/jobs.md Normal file
View File

0
sdl-mgr/html/md/proj.md Normal file
View File

0
sdl-mgr/html/md/wkrs.md Normal file
View File

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

5
sdl-wkr/.gitignore vendored Normal file
View File

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

9
sdl-wkr/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
sdl-wkr/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
sdl-wkr/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,575 @@
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';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// console.log("DEBUG: __dirname: " + __dirname);
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 sdl_home = path.join(__dirname, '..', '..', '..', '..', '..'); // SDL_HOME Default
let sdl_wkr_cfg_file = path.join(sdl_home, 'conf', 'sdl-wkr-config.json');
// console.log("DEBUG: SDL_HOME: " + sdl_home);
// console.log("DEBUG: SDL_WKR_CFG_FILE: " + sdl_wkr_cfg_file);
try {
fs.accessSync(sdl_wkr_cfg_file, fs.constants.R_OK);
} catch (err) {
console.error(err);
process.exit(1);
}
// // Load from ENV
// if (process.env.SDL_HOME) {
// sdl_home = process.env.SDL_HOME;
// sdl_wkr_cfg_file = path.join(sdl_home, 'conf', 'sdl-wkr-config.json');
// // console.log("DEBUG3: SDL_HOME: " + sdl_home);
// // console.log("DEBUG3: SDL_WKR_CFG_FILE: " + sdl_wkr_cfg_file);
// try {
// fs.accessSync(sdl_wkr_cfg_file, fs.constants.R_OK);
// } catch (err) {
// console.error(err);
// process.exit(1);
// }
// }
// // Load from ARGV
// if (argv.configfile) {
// sdl_home = path.dirname(argv.configfile);
// sdl_wkr_cfg_file = path.join(sdl_home, 'conf', 'sdl-wkr-config.json');
// // console.log("DEBUG4: SDL_HOME: " + sdl_home);
// // console.log("DEBUG4: SDL_WKR_CFG_FILE: " + sdl_wkr_cfg_file);
// try {
// fs.accessSync(sdl_wkr_cfg_file, fs.constants.R_OK);
// } catch (err) {
// console.error(err);
// process.exit(1);
// }
// }
// console.log("DEBUG: SDL_HOME: " + sdl_home);
// console.log("DEBUG: SDL_WKR_CFG_FILE: " + sdl_wkr_cfg_file);
// Load Config
try {
config = JSON.parse(fs.readFileSync(sdl_wkr_cfg_file, 'utf8'));
config.dirs.base = config.dirs.base + '/..'
} catch (err) {
console.error(err);
}
// console.log("DEBUG: sdl_home: " + sdl_home);
// Expand Data Directory
let search_replace = {}
search_replace['DIRNAME'] = __dirname
// search_replace['SDLHOME'] = sdl_home
config.dirs['SDLHOME'] = sdl_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 = {}
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: {
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.identity = {
sdl_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;
}
// -------------------------------
// 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
}
}
// -------------------------------
// SDL Identity
// -------------------------------
try {
const idinfo = load_or_create_sdl_id(config.dirs.sdlhome);
config.identity.sdl_id = idinfo.sdl_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_sdl_id(sdl_home) {
const id_dir = path.join(sdl_home, 'conf');
const id_file = path.join(id_dir, 'sdl-id.json');
try {
if (fs.existsSync(id_file)) {
const data = JSON.parse(fs.readFileSync(id_file, 'utf8'));
if (data.sdl_id) {
return data;
}
}
} catch (err) {
console.error('Failed to read sdl-id.json:', err);
process.exit(1);
}
// Create new identity
const sdl_id = {
sdl_id: crypto.randomUUID(),
created_at: new Date().toISOString(),
hostname: os.hostname()
};
try {
fs.mkdirSync(id_dir, { recursive: true });
fs.writeFileSync(id_file, JSON.stringify(sdl_id, null, 2), { mode: 0o600 });
} catch (err) {
console.error('Failed to write sdl-id.json:', err);
process.exit(1);
}
return sdl_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;
}
export { load_config, log, ipToInt, intToIp, computeBroadcast };

View File

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

View File

@ -0,0 +1,130 @@
const module = 'sdl-wkr'; // Module Name
import { load_config, log } from '../nwa-lib/index.js';
import dgram from 'dgram';
import mqtt from 'mqtt';
import os from 'os';
const config = load_config();
log(`Loaded module: ${module}`);
// log(`${module}: CONFIG: ${JSON.stringify(config, null, 2)}`, true);
// -------------------------------
// Worker state
// -------------------------------
let joined = false;
let mqttClient = null;
// -------------------------------
// Start UDP discovery
// -------------------------------
function startUdpListener() {
if (!config.modules?.[module]?.enabled) {
log(`${module}: disabled, not starting UDP listener`);
return;
}
const udpPort = config.modules[module]['sdl-mgr-udp-port'];
if (!Number.isInteger(udpPort)) {
log(`${module}: sdl-mgr-udp-port not configured`);
return;
}
const socket = dgram.createSocket('udp4');
socket.on('error', err => {
log(`${module}: UDP socket error: ${err}`);
});
socket.on('message', (msg, rinfo) => {
if (joined) return;
try {
const beacon = JSON.parse(msg.toString());
if (beacon?.sdl?.cluster?.id == null) return;
if (beacon?.sdl?.mqtt?.host == null) return;
log(`${module}: received SDL beacon from ${rinfo.address}`);
log(`${module}: UDP beacon: ${JSON.stringify(beacon)}`, false);
handleBeacon(beacon);
} catch {
// ignore garbage
}
});
socket.bind(udpPort, '0.0.0.0', () => {
socket.setBroadcast(true);
log(`${module}: listening for SDL beacons on UDP ${udpPort}`);
});
}
// -------------------------------
// Handle beacon
// -------------------------------
function handleBeacon(beacon) {
const sdl = beacon.sdl;
const mqttInfo = sdl.mqtt;
// Cluster match (strict)
const expectedClusterId =
config.modules[module].cluster_id || sdl.cluster.id;
if (sdl.cluster.id !== expectedClusterId) {
log(
`${module}: ignoring beacon for cluster ${sdl.cluster.id}`
);
return;
}
log(
`${module}: joining cluster '${sdl.cluster.name}' via MQTT ${mqttInfo.host}:${mqttInfo.port}`
);
joinCluster(mqttInfo, sdl.cluster);
}
// -------------------------------
// Join cluster via MQTT
// -------------------------------
function joinCluster(mqttInfo, cluster) {
joined = true;
const mqttUrl = `mqtt://${mqttInfo.host}:${mqttInfo.port}`;
mqttClient = mqtt.connect(mqttUrl);
mqttClient.on('connect', () => {
log(`${module}: connected to MQTT broker at ${mqttUrl}`);
const joinPayload = {
type: 'sdl_worker_join',
timestamp: new Date().toISOString(),
sdl_wkr: config.identity.hostname,
sdl_id: config.identity.sdl_id
};
mqttClient.publish(
mqttInfo.join_topic,
JSON.stringify(joinPayload),
{ qos: 1 },
err => {
if (err) {
log(`${module}: failed to publish join message: ${err}`);
} else {
log(`${module}: published join request to ${mqttInfo.join_topic}`);
}
}
);
});
mqttClient.on('error', err => {
log(`${module}: MQTT error: ${err}`);
});
}
// -------------------------------
// Entry point
// -------------------------------
startUdpListener();

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

1207
sdl-wkr/app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
sdl-wkr/app/package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "sdl-wkr",
"description": "Software Defined Laboratory: Worker (sdl-wkr)",
"version": "0.3.0",
"version_date": "2026-01-31",
"main": "index.js",
"type": "module",
"scripts": {
"start": "node index.js"
},
"repository": {
"type": "git",
"url": "https://github.com/jehaverlack/sdl.git"
},
"author": "John Haverlack",
"copyright": "2026 John Haverlack",
"license": "MIT",
"license_url": "https://opensource.org/license/MIT",
"bugs": {
"url": ""
},
"homepage": "https://github.com/jehaverlack/sdl",
"dependencies": {
"csv-parse": "^5.6.0",
"mqtt": "^5.14.1",
"node-fetch": "^3.3.2",
"open": "^11.0.0",
"yargs": "^18.0.0"
}
}

View File

@ -0,0 +1,17 @@
{
"dirs": {
"base": "DIRNAME/../..",
"sdlhome": "SDLHOME",
"app": "BASE/app",
"modules": "APP/modules",
"logs": "SDLHOME/logs",
"data": "SDLHOME/data",
"proj": "DATA/projects",
"jobs": "DATA/jobs",
"conf": "SDLHOME/conf",
"modconf": "BASE/conf/modules"
},
"modules": {
"sdl-wkr": {}
}
}

View File

@ -0,0 +1,4 @@
{
"enabled": true,
"sdl-mgr-udp-port": 10101
}

View File

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

View File

@ -0,0 +1,263 @@
#!/usr/bin/env bash
# install-sdl-wkr.sh - Script to install SDL Worker
# Usage: curl -s http://<SDL_MGR_IP>:8081/dist/install-sdl-wkr.sh | bash -s <SDL_MGR_IP>
set -euo pipefail
# ------------------------------------------------------------
# Functions
# ------------------------------------------------------------
install_nodejs() {
# ------------------------------------------------------------
# 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
# case "$OS_PLATFORM" in
# linux) NODE_PLATFORM="linux" ;;
# darwin) NODE_PLATFORM="darwin" ;;
# win) NODE_PLATFORM="win" ;;
# *)
# echo "ERROR: Unsupported platform for NodeJS"
# exit 1
# ;;
# esac
case "$OS_PLATFORM" in
linux) EXT="tar.xz" ;;
darwin) EXT="tar.gz" ;;
win) EXT="zip" ;;
*) echo "ERROR: Unknown NodeJS target OS: $OS_ID"; exit 1 ;;
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
NODE_VER=$(curl -s http://${SDL_MGR_IP}:8081/api/config | jq -r '.nodejs.version')
NODE_DIR="${SDL_HOME}/nodejs"
NODE_VER_BASE="node-${NODE_VER}-${OS_PLATFORM}-${OS_ARCH}"
NODE_FILE="node-${NODE_VER}-${OS_PLATFORM}-${OS_ARCH}.${EXT}"
# NODE_VER_BASE="node-${NODE_VER}-${NODE_PLATFORM}-${OS_ARCH}"
# NODE_FILE="${NODE_VER_BASE}.${EXT}"
NODE_URL="http://${SDL_MGR_IP}:8081/dist/${NODE_FILE}"
#echo "NodeJS Version: ${NODE_VER}"
#echo "NodeJS URL: ${NODE_URL}"
mkdir -p "${NODE_DIR}"
curl -sSL --fail "${NODE_URL}" -o "${TMP_DIR}/${NODE_FILE}" || { echo "Failed to download Node.js"; exit 1; }
# test Checksum
curl -sSL --fail "${NODE_URL}.sha256" -o "${TMP_DIR}/${NODE_FILE}.sha256" || { echo "Failed to download Node.js SHA256"; exit 1; }
cd "${TMP_DIR}"
sha256sum -c "${NODE_FILE}.sha256"
if [ $? -ne 0 ]; then
echo "SHA256 checksum verification failed."
exit 1
fi
if ([ "${EXT}" == "zip" ]); then
unzip "${TMP_DIR}/${NODE_FILE}" -d "${NODE_DIR}"
elif ([ "${EXT}" == "tar.xz" ] || [ "${EXT}" == "tar.xz" ]); then
tar -xJf "${TMP_DIR}/${NODE_FILE}" -C "${NODE_DIR}"
elif ([ "${EXT}" == "tar.gz" ] || [ "${EXT}" == "tar.xz" ]); then
tar -xzf "${TMP_DIR}/${NODE_FILE}" -C "${NODE_DIR}"
fi
# tar -xzf "${TMP_DIR}/${NODE_FILE}" -C "${NODE_DIR}"
rm -f "${TMP_DIR}/${NODE_FILE}"
ln -sfn "${NODE_DIR}/${NODE_VER_BASE}" "${NODE_DIR}/current"
}
# ------------------------------------------------------------
# Require non root user
# ------------------------------------------------------------
if [[ $EUID -eq 0 ]]; then
echo "ERROR: install-sdl-wkr.sh must be run as a non-root user"
exit 1
fi
# ------------------------------------------------------------
# Requirements
# ------------------------------------------------------------
for cmd in awk curl grep jq sed sha256sum tar unzip; do
command -v "$cmd" >/dev/null || {
echo "ERROR: $cmd is required"
echo "Please install:"
echo " sudo apt -y install gawk coreutils curl jq grep sed sha256sum tar unzip"
exit 1
}
done
# Check if SDL-MGR IP is provided as an argument
if [ -z "$1" ]; then
echo "Usage: $0 <SDL_MGR_IP>"
exit 1
fi
SDL_MGR_IP=$1
SDL_HOME=$(curl -s http://${SDL_MGR_IP}:8081/api/config | jq -r '.dirs.sdlhome')
SDL_VER=$(curl -s http://${SDL_MGR_IP}:8081/api/config | jq -r '.package.version')
SDL_DESC=$(curl -s http://${SDL_MGR_IP}:8081/api/config | jq -r '.package.description')
SDL_ABBR=$(curl -s http://${SDL_MGR_IP}:8081/api/config | jq -r '.package.abbr')
SDL_COPYRT=$(curl -s http://${SDL_MGR_IP}:8081/api/config | jq -r '.package.copyright')
echo "###############################################"
echo "${SDL_DESC} (${SDL_ABBR})"
echo "Version: ${SDL_VER}"
echo "Copyright: (C) ${SDL_COPYRT}"
echo "Installing SDL Worker..."
echo " to: ${SDL_HOME}"
echo ""
TMP_DIR="${SDL_HOME}/tmp"
SDL_WKR_DIR="${SDL_HOME}/sdl-wkr"
SDL_WKR_VERSION=$(curl -s http://${SDL_MGR_IP}:8081/api/config | jq -r '.package.version')
SDL_WKR_TGZ="sdl-wkr_${SDL_WKR_VERSION}.tgz"
SDL_CONF_DIR="${SDL_HOME}/conf"
SLD_LOGS_DIR="${SDL_HOME}/logs"
# ------------------------------------------------------------
# Create Directories
# ------------------------------------------------------------
mkdir -p "${SDL_HOME}"
mkdir -p "${SDL_CONF_DIR}"
mkdir -p "${SLD_LOGS_DIR}"
mkdir -p "${SDL_WKR_DIR}"
mkdir -p "${TMP_DIR}"
# Download the latest sdl-wkr version to TMP_DIR
WKR_TGZ_URL="http://${SDL_MGR_IP}:8081/dist/${SDL_WKR_TGZ}"
WKR_TGZ_SHA256_URL="http://${SDL_MGR_IP}:8081/dist/${SDL_WKR_TGZ}.sha256"
curl -sSL --fail "${WKR_TGZ_URL}" -o "${TMP_DIR}/${SDL_WKR_TGZ}" || { echo "Failed to download sdl-wkr"; exit 1; }
curl -sSL --fail "${WKR_TGZ_SHA256_URL}" -o "${TMP_DIR}/${SDL_WKR_TGZ}.sha256" || { echo "Failed to download sdl-wkr SHA256"; exit 1; }
# Verify the SHA256 checksum
cd "${TMP_DIR}"
sha256sum --quiet -c "${TMP_DIR}/${SDL_WKR_TGZ}.sha256"
if [ $? -ne 0 ]; then
echo "SHA256 checksum verification failed."
exit 1
fi
cd "${SDL_HOME}"
# Extract and move to versioned directory
VERSIONED_DIR="${SDL_WKR_DIR}/sdl-wkr_${SDL_WKR_VERSION}"
rm -rf "${VERSIONED_DIR}"
tar -xzf "${TMP_DIR}/${SDL_WKR_TGZ}" -C "${SDL_WKR_DIR}"
mv "${SDL_WKR_DIR}/sdl-wkr" "${VERSIONED_DIR}"
# Create a symlink for the current version
if [ -d "${SDL_WKR_DIR}/current" ]; then
rm -rf "${SDL_WKR_DIR}/current"
fi
ln -sfn "${VERSIONED_DIR}" "${SDL_WKR_DIR}/current"
# Copy initial configuration files
if [ ! -e "${SDL_HOME}/conf/sdl-wkr-config.json" ]; then
cp "${SDL_WKR_DIR}/current/conf/default-sdl-wkr-config.json" "${SDL_HOME}/conf/sdl-wkr-config.json"
fi
mkdir -p ~/.config/systemd/user
cp "$SDL_WKR_DIR/current/scripts/sdl-wkr.service" ~/.config/systemd/user/
# Install Node.js idempotently
NODE_VER=$(curl -s http://${SDL_MGR_IP}:8081/api/config | jq -r '.nodejs.version')
NODE_DIR="${SDL_HOME}/nodejs"
NODE_BIN="${NODE_DIR}/current/bin/node"
NPM_BIN="${NODE_DIR}/current/bin/npm"
# echo "NodeJS Version: ${NODE_VER}"
# echo "NodeJS BIN version: ${NODE_BIN_VER}"
# echo "NodeJS BIN: ${NODE_BIN}"
if [ -e "${NODE_BIN}" ]; then
NODE_BIN_VER=$(${NODE_BIN} --version)
if [ ${NODE_BIN_VER} != "${NODE_VER}" ]; then
install_nodejs
fi
else
install_nodejs
fi
# ------------------------------------------------------------
# Install sdl-mgr Node Dependencies
# ------------------------------------------------------------
echo ""
echo "Installing NodeJS Dependencies"
cd "$SDL_WKR_DIR/current/app"
export PATH="$NODE_DIR/current/bin:$PATH"
$NPM_BIN install
# Start sdl-wkr systemd service
systemctl --user daemon-reload --no-pager
systemctl --user restart sdl-wkr --no-pager
sleep 3
systemctl --user status sdl-wkr --no-pager
# systemd commands
echo ""
echo "SDL Worker deployed:"
echo ""
echo "SDL Controls:"
echo " systemctl --user status sdl-wkr"
echo " systemctl --user start sdl-wkr"
echo " systemctl --user stop sdl-wkr"
echo ""
echo "Removal Commands:"
echo " systemctl --user disable sdl-wkr"
echo " rm -rf $SDL_HOME"

View File

@ -0,0 +1,29 @@
[Unit]
Description=SDL Worker
After=network.target
[Service]
Type=simple
# Paths
Environment=SDL_HOME=%h/.sdl
WorkingDirectory=%h/.sdl/sdl-wkr/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-wkr.out.log
StandardError=append:%h/.sdl/logs/sdl-wkr.err.log
# Clean shutdown
KillSignal=SIGTERM
TimeoutStopSec=15
[Install]
WantedBy=default.target

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