Start v0.3.0
This commit is contained in:
parent
bafa565131
commit
53f095146e
|
|
@ -0,0 +1,4 @@
|
|||
dist/*
|
||||
sdl-home/logs/*
|
||||
sdl-home/data/*
|
||||
sdl-id.json
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 John Haverlack
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -0,0 +1,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
|
||||
```
|
||||
|
||||
|
|
@ -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": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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 Manager’s advertised version via MQTT cluster status messages and will automatically re-install and restart when a newer version is detected.
|
||||
|
||||
Alternatively you can upgrade manually by rerunning the install-sdl-wkr.sh script.
|
||||
|
||||
```
|
||||
curl -s http://<SDL_MGR_IP>:<WEB_PORT>/dist/install-sdl-wkr.sh | bash -s <SDL_MGR_IP>
|
||||
```
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
# Developer Workflow
|
||||
|
||||
## 1. Start from clean main branch
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull
|
||||
```
|
||||
|
||||
Ensure no local changes
|
||||
|
||||
```bash
|
||||
git status -s
|
||||
```
|
||||
|
||||
## 2. Generate new branch
|
||||
|
||||
```bash
|
||||
./tools/new-version.sh
|
||||
```
|
||||
|
||||
## 3. Commit changes
|
||||
|
||||
```bash
|
||||
git add <files>
|
||||
git commit -m "feat: <description>"
|
||||
```
|
||||
|
||||
## 4. Update Changelog
|
||||
|
||||
```bash
|
||||
./tools/changelog-update.sh
|
||||
```
|
||||
|
||||
**Optionally**: Update changelog.json Summary and Notes
|
||||
|
||||
Generate CHANGELOG.md
|
||||
|
||||
```bash
|
||||
./tools/genmd-changelog.sh
|
||||
```
|
||||
|
||||
## 5. Merge into main
|
||||
|
||||
```bash
|
||||
./tools/main-merge.sh
|
||||
```
|
||||
|
||||
## 6. Push to GitHub
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
|
||||
## 7. Push Tags to GitHub
|
||||
|
||||
```bash
|
||||
git push origin --tags
|
||||
```
|
||||
|
||||
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
conf/secrets/*
|
||||
app/node_modules/*
|
||||
data/*
|
||||
logs/*
|
||||
**/.~lock*#
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# LICENSE: MIT
|
||||
|
||||
Copyright 2025 John Haverlack
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
# node-web-app
|
||||
Node.js Web Application
|
||||
|
||||
# Purpose
|
||||
|
||||
This web application template provides a modular template for building a node.js web application with an API and UI.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
node-web-app
|
||||
├── app
|
||||
│ └── modules
|
||||
│ ├── template
|
||||
│ └── web
|
||||
├── conf
|
||||
│ ├── modules
|
||||
│ └── secrets
|
||||
├── data
|
||||
│ └── sqlite3
|
||||
├── docs
|
||||
├── html
|
||||
│ ├── conf
|
||||
│ ├── css
|
||||
│ ├── img
|
||||
│ ├── js
|
||||
│ └── md
|
||||
└── logs
|
||||
```
|
||||
|
||||
|
||||
|
||||
# Getting Started
|
||||
|
||||
## Pre-requisites
|
||||
- [Node.js](https://nodejs.org/en) 22.x
|
||||
|
||||
### Optional for Development
|
||||
- [DB Browser for SQLite](https://sqlitebrowser.org/]
|
||||
- To view data in **data/sqlite/nwa.db**
|
||||
|
||||
## Clone Repo
|
||||
|
||||
```
|
||||
git clone
|
||||
```
|
||||
|
||||
## Install Dependencies
|
||||
|
||||
```
|
||||
cd node-web-app/app
|
||||
```
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
## Starting the application
|
||||
|
||||
Ensure you are in the `node-web-app/app` directory
|
||||
|
||||
```
|
||||
npm start
|
||||
```
|
||||
|
||||
```
|
||||
$ node-web-app/app$ npm start
|
||||
|
||||
> node-web-app@1.0.0 start
|
||||
> node index.js
|
||||
|
||||
web: INFO: WebUI running on 127.0.0.1:8080
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
// Node Web Application (NWA)
|
||||
// John Haverlack <john@haverlack.net>
|
||||
// https://github.com/
|
||||
// LICENSE: MIT
|
||||
// Copyright (c) 2025 John Haverlack
|
||||
|
||||
import { load_config, log } from './modules/nwa-lib/index.js';
|
||||
|
||||
const config = load_config();
|
||||
|
||||
log('=======================================================================================');
|
||||
log(config.package.name + ': STARTING: ' + config.package.description + ' v' + config.package.version);
|
||||
// log(JSON.stringify(config, null, 2));
|
||||
log(JSON.stringify(config.dirs, null, 2), false);
|
||||
|
||||
for (let m in config.modules) {
|
||||
// log(`Loading module: ${m}`);
|
||||
await import(config.modules[m].main);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,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}`);
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"main": "index.js",
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"main": "index.js",
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"main": "index.js",
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
const module = 'TEMPLATE'; // Module Name
|
||||
import { load_config, log } from '../nwa-lib/index.js';
|
||||
const config = load_config();
|
||||
|
||||
log(`Loaded module: ${module}`);
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"main": "index.js",
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,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;
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"main": "index.js",
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"enabled": true
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>404 - Not Found</title>
|
||||
<link rel="stylesheet" href="/css/nwa.css" />
|
||||
</head>
|
||||
<body>
|
||||
<h1>404 - Page Not Found</h1>
|
||||
<p>The requested resource could not be found on this server.</p>
|
||||
<a href="/">Return to Home</a>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<!-- <header class="custom-header py-2 border-bottom">
|
||||
<div class="container d-flex align-items-center justify-content-between flex-wrap"> -->
|
||||
|
||||
<!-- Title -->
|
||||
<!-- <h1 class="h5 text-white text-end mt-2 mt-sm-0">
|
||||
<span id="banner-title"></span>
|
||||
</h1>
|
||||
</div>
|
||||
</header> -->
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
{
|
||||
"sdl":{
|
||||
"title": "SDL",
|
||||
"desc": "Software Defined Laboratory",
|
||||
"icon": "fa-solid fa-square-binary",
|
||||
"page": "index",
|
||||
"enabled": false,
|
||||
"items": []
|
||||
},
|
||||
"dash": {
|
||||
"title": "Dash",
|
||||
"desc": "Dashboard",
|
||||
"icon": "fa-solid fa-gauge",
|
||||
"page": "dash",
|
||||
"enabled": 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
|
|
@ -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%);
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
body {
|
||||
font-family: sans-serif;
|
||||
/* margin: 2rem; */
|
||||
}
|
||||
|
||||
header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #ccc;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
#page {
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.custom-header {
|
||||
background-color: #003764;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
footer {
|
||||
--bs-secondary-color: #666;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 231 KiB |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M418.4 157.9c35.3-8.3 61.6-40 61.6-77.9c0-44.2-35.8-80-80-80c-43.4 0-78.7 34.5-80 77.5L136.2 151.1C121.7 136.8 101.9 128 80 128c-44.2 0-80 35.8-80 80s35.8 80 80 80c12.2 0 23.8-2.7 34.1-7.6L259.7 407.8c-2.4 7.6-3.7 15.8-3.7 24.2c0 44.2 35.8 80 80 80s80-35.8 80-80c0-27.7-14-52.1-35.4-66.4l37.8-207.7zM156.3 232.2c2.2-6.9 3.5-14.2 3.7-21.7l183.8-73.5c3.6 3.5 7.4 6.7 11.6 9.5L317.6 354.1c-5.5 1.3-10.8 3.1-15.8 5.5L156.3 232.2z"/></svg>
|
||||
|
After Width: | Height: | Size: 656 B |
Binary file not shown.
|
After Width: | Height: | Size: 231 KiB |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M0 96C0 60.7 28.7 32 64 32l320 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zm144 4c-24.3 0-44 19.7-44 44l0 48c0 24.3 19.7 44 44 44l32 0c24.3 0 44-19.7 44-44l0-48c0-24.3-19.7-44-44-44l-32 0zm-4 44c0-2.2 1.8-4 4-4l32 0c2.2 0 4 1.8 4 4l0 48c0 2.2-1.8 4-4 4l-32 0c-2.2 0-4-1.8-4-4l0-48zm140-44c-11 0-20 9-20 20c0 9.7 6.9 17.7 16 19.6l0 76.4c0 11 9 20 20 20s20-9 20-20l0-96c0-11-9-20-20-20l-16 0zM132 296c0 9.7 6.9 17.7 16 19.6l0 76.4c0 11 9 20 20 20s20-9 20-20l0-96c0-11-9-20-20-20l-16 0c-11 0-20 9-20 20zm96 24l0 48c0 24.3 19.7 44 44 44l32 0c24.3 0 44-19.7 44-44l0-48c0-24.3-19.7-44-44-44l-32 0c-24.3 0-44 19.7-44 44zm44-4l32 0c2.2 0 4 1.8 4 4l0 48c0 2.2-1.8 4-4 4l-32 0c-2.2 0-4-1.8-4-4l0-48c0-2.2 1.8-4 4-4z"/></svg>
|
||||
|
After Width: | Height: | Size: 976 B |
|
|
@ -0,0 +1,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>
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
fetch('/api/config')
|
||||
.then(res => res.json())
|
||||
.then(config => {
|
||||
|
||||
// Package Vars
|
||||
for (let pkvar in config.package) {
|
||||
let appvar = 'app_' + pkvar;
|
||||
|
||||
// Select *all* elements with this class
|
||||
const elements = document.querySelectorAll('.' + appvar);
|
||||
// console.log(`Found ${elements.length} elements with .${appvar}`);
|
||||
|
||||
elements.forEach(el => {
|
||||
// console.log(`Setting .${appvar} = ${config.package[pkvar]}`);
|
||||
|
||||
switch (pkvar) {
|
||||
case 'license': {
|
||||
const lic = config.package.license;
|
||||
const licUrl = config.package.license_url;
|
||||
|
||||
if (licUrl) {
|
||||
el.innerHTML = `<a target="_blank" href="${licUrl}">${lic}</a>`;
|
||||
} else {
|
||||
el.textContent = lic;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'homepage':
|
||||
let home_html = `<a target="_blank" href="${config.package[pkvar]}">${config.package[pkvar]}</a>`;
|
||||
el.innerHTML = home_html;
|
||||
break;
|
||||
default:
|
||||
el.innerHTML = config.package[pkvar];
|
||||
break;
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
// Node.js
|
||||
let node_html = '';
|
||||
node_html += '<ul class="">'
|
||||
node_html += ` <li><b>Version:</b> ${config.nodejs.version}</li>`
|
||||
node_html += ` <li><b>Arch:</b> ${config.nodejs.arch}</li>`
|
||||
node_html += ` <li><b>Platform:</b> ${config.nodejs.platform}</li>`
|
||||
node_html += '</ul>';
|
||||
|
||||
document.getElementById('nodejs').innerHTML = node_html;
|
||||
|
||||
// Dependencies
|
||||
let dep_keys = ['name', 'version', 'license', 'homepage'];
|
||||
let dep_html = '';
|
||||
|
||||
dep_html += '<div class="row g-3">'; // g-3 adds spacing between cards
|
||||
|
||||
for (let dep in config.dependencies) {
|
||||
const depInfo = config.dependencies[dep];
|
||||
console.log('Dep: ' + dep);
|
||||
|
||||
dep_html += `
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<div class="card shadow-sm border-0 h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-primary">${depInfo.name || dep}</h5>
|
||||
<ul class="list-unstyled small mb-0">
|
||||
`;
|
||||
|
||||
for (let key of dep_keys) {
|
||||
if (!depInfo[key]) continue;
|
||||
if (key === 'homepage') {
|
||||
dep_html += `
|
||||
<li><b>${key}:</b>
|
||||
<a href="${depInfo[key]}" target="_blank" class="link-primary text-decoration-none">
|
||||
${depInfo[key]}
|
||||
</a>
|
||||
</li>
|
||||
`;
|
||||
} else {
|
||||
dep_html += `<li><b>${key}:</b> ${depInfo[key]}</li>`;
|
||||
}
|
||||
}
|
||||
|
||||
dep_html += `
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
dep_html += '</div>';
|
||||
|
||||
document.getElementById('dependancies').innerHTML = dep_html;
|
||||
|
||||
|
||||
})
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
const host = window.location.host; // includes hostname and port
|
||||
const link = document.getElementById('api-link');
|
||||
if (link) {
|
||||
link.href = `http://${host}/api`;
|
||||
link.textContent = `http://${host}/api`;
|
||||
}
|
||||
|
|
@ -0,0 +1,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,0 +1 @@
|
|||
|
||||
|
|
@ -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 © <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,0 +1,22 @@
|
|||
|
||||
## <span class="app_description"></span>
|
||||
|
||||
- **App:** <span class="app_name"></span>
|
||||
- **Version:** <span class="app_version"></span
|
||||
- **Author:** <span class="app_author"></span>
|
||||
- **Copyright:** <span class="app_copyright"></span>
|
||||
- **License:** <span class="app_license"></span>
|
||||
- **Homepage:** <span class="app_homepage"></span>
|
||||
|
||||
## Node.JS
|
||||
|
||||
<div id="nodejs"></div>
|
||||
|
||||
## Dependancies
|
||||
|
||||
<div id="dependancies"></div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
|
||||
You can access the JSON API Usage / Nav at this link:
|
||||
|
||||
<ul>
|
||||
<li><a target="_blank" id="api-link" href="/api">http://localhost:8411/api</a></li>
|
||||
</ul>
|
||||
|
|
@ -0,0 +1,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
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 Manager’s advertised version via MQTT cluster status messages and will automatically re-install and restart when a newer version is detected.
|
||||
|
||||
Alternatively you can upgrade manually by rerunning the install-sdl-wkr.sh script.
|
||||
|
||||
```
|
||||
curl -s http://<SDL_MGR_IP>:<WEB_PORT>/dist/install-sdl-wkr.sh | bash -s <SDL_MGR_IP>
|
||||
```
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
A modern parallel compute engine for conducting Computational Experiments.
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
[Unit]
|
||||
Description=SDL Manager
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
# Paths
|
||||
Environment=SDL_HOME=%h/.sdl
|
||||
|
||||
WorkingDirectory=%h/.sdl/sdl-mgr/current/app
|
||||
|
||||
# Start command
|
||||
ExecStart=%h/.sdl/nodejs/current/bin/node index.js
|
||||
|
||||
# Restart behavior
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
|
||||
# Logging
|
||||
StandardOutput=append:%h/.sdl/logs/sdl-mgr.out.log
|
||||
StandardError=append:%h/.sdl/logs/sdl-mgr.err.log
|
||||
|
||||
# Clean shutdown
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=15
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
conf/secrets/*
|
||||
app/node_modules/*
|
||||
data/*
|
||||
logs/*
|
||||
**/.~lock*#
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# LICENSE: MIT
|
||||
|
||||
Copyright 2025 John Haverlack
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
# node-web-app
|
||||
Node.js Web Application
|
||||
|
||||
# Purpose
|
||||
|
||||
This web application template provides a modular template for building a node.js web application with an API and UI.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
node-web-app
|
||||
├── app
|
||||
│ └── modules
|
||||
│ ├── template
|
||||
│ └── web
|
||||
├── conf
|
||||
│ ├── modules
|
||||
│ └── secrets
|
||||
├── data
|
||||
│ └── sqlite3
|
||||
├── docs
|
||||
├── html
|
||||
│ ├── conf
|
||||
│ ├── css
|
||||
│ ├── img
|
||||
│ ├── js
|
||||
│ └── md
|
||||
└── logs
|
||||
```
|
||||
|
||||
|
||||
|
||||
# Getting Started
|
||||
|
||||
## Pre-requisites
|
||||
- [Node.js](https://nodejs.org/en) 22.x
|
||||
|
||||
### Optional for Development
|
||||
- [DB Browser for SQLite](https://sqlitebrowser.org/]
|
||||
- To view data in **data/sqlite/nwa.db**
|
||||
|
||||
## Clone Repo
|
||||
|
||||
```
|
||||
git clone
|
||||
```
|
||||
|
||||
## Install Dependencies
|
||||
|
||||
```
|
||||
cd node-web-app/app
|
||||
```
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
## Starting the application
|
||||
|
||||
Ensure you are in the `node-web-app/app` directory
|
||||
|
||||
```
|
||||
npm start
|
||||
```
|
||||
|
||||
```
|
||||
$ node-web-app/app$ npm start
|
||||
|
||||
> node-web-app@1.0.0 start
|
||||
> node index.js
|
||||
|
||||
web: INFO: WebUI running on 127.0.0.1:8080
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
// Node Web Application (NWA)
|
||||
// John Haverlack <john@haverlack.net>
|
||||
// https://github.com/
|
||||
// LICENSE: MIT
|
||||
// Copyright (c) 2025 John Haverlack
|
||||
|
||||
import { load_config, log } from './modules/nwa-lib/index.js';
|
||||
|
||||
const config = load_config();
|
||||
|
||||
log('=======================================================================================');
|
||||
log(config.package.name + ': STARTING: ' + config.package.description + ' v' + config.package.version);
|
||||
// log(JSON.stringify(config, null, 2));
|
||||
log(JSON.stringify(config.dirs, null, 2), false);
|
||||
|
||||
for (let m in config.modules) {
|
||||
// log(`Loading module: ${m}`);
|
||||
await import(config.modules[m].main);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,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 };
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"main": "index.js",
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"main": "index.js",
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
const module = 'TEMPLATE'; // Module Name
|
||||
import { load_config, log } from '../nwa-lib/index.js';
|
||||
const config = load_config();
|
||||
|
||||
log(`Loaded module: ${module}`);
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"main": "index.js",
|
||||
"type": "module"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"enabled": true,
|
||||
"sdl-mgr-udp-port": 10101
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"enabled": true
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
#!/usr/bin/env bash
|
||||
# changelog-update.sh
|
||||
#
|
||||
# Idempotently maintain the changelog entry for the active version.
|
||||
# - Updates METADATA.version_date
|
||||
# - Syncs maturity
|
||||
# - Ensures exactly one entry per version
|
||||
# - Updates or creates the active changelog entry
|
||||
# - Regenerates docs/CHANGELOG.md
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VERSION=$(jq -r '.METADATA.version' metadata.json)
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
MATURITY=$(jq -r '.METADATA.maturity' metadata.json)
|
||||
|
||||
tmp_meta=$(mktemp)
|
||||
tmp_changelog=$(mktemp)
|
||||
trap 'rm -f "$tmp_meta" "$tmp_changelog"' EXIT
|
||||
|
||||
# -------------------------------------------------
|
||||
# Update version_date in metadata.json
|
||||
# -------------------------------------------------
|
||||
jq --arg d "$DATE" '
|
||||
.METADATA.version_date = $d
|
||||
' metadata.json > "$tmp_meta"
|
||||
|
||||
mv "$tmp_meta" metadata.json
|
||||
|
||||
# Propagate metadata into files
|
||||
./tools/metadata-update.sh
|
||||
|
||||
# -------------------------------------------------
|
||||
# Ensure changelog.json exists and is valid
|
||||
# -------------------------------------------------
|
||||
if [[ ! -f changelog.json ]]; then
|
||||
echo '{"releases":[]}' > changelog.json
|
||||
fi
|
||||
|
||||
if ! jq -e '.releases | type == "array"' changelog.json >/dev/null 2>&1; then
|
||||
echo "ERROR: changelog.json must contain a single 'releases' array"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# -------------------------------------------------
|
||||
# Normalize: enforce one entry per version (keep newest)
|
||||
# -------------------------------------------------
|
||||
# jq '
|
||||
# .releases |=
|
||||
# ( . | reverse
|
||||
# | unique_by(.version)
|
||||
# | reverse
|
||||
# )
|
||||
# ' changelog.json > "$tmp_changelog"
|
||||
jq '
|
||||
def semver:
|
||||
(.version
|
||||
| split(".")
|
||||
| map(tonumber));
|
||||
|
||||
.releases |=
|
||||
(
|
||||
sort_by(semver) # oldest → newest
|
||||
| reverse # newest → oldest
|
||||
| unique_by(.version) # keep newest entry per version
|
||||
| sort_by(semver)
|
||||
| reverse
|
||||
)
|
||||
' changelog.json > "$tmp_changelog"
|
||||
|
||||
|
||||
mv "$tmp_changelog" changelog.json
|
||||
|
||||
# -------------------------------------------------
|
||||
# Update or add active version entry
|
||||
# -------------------------------------------------
|
||||
if jq -e --arg v "$VERSION" '.releases[] | select(.version == $v)' changelog.json >/dev/null; then
|
||||
echo "Updating changelog entry for v${VERSION}"
|
||||
|
||||
jq --arg v "$VERSION" --arg d "$DATE" --arg m "$MATURITY" '
|
||||
.releases |=
|
||||
map(
|
||||
if .version == $v then
|
||||
.date = $d
|
||||
| .maturity = $m
|
||||
else
|
||||
.
|
||||
end
|
||||
)
|
||||
' changelog.json > "$tmp_changelog"
|
||||
else
|
||||
echo "Adding changelog entry for v${VERSION}"
|
||||
|
||||
jq --arg v "$VERSION" --arg d "$DATE" --arg m "$MATURITY" '
|
||||
.releases |=
|
||||
([{
|
||||
version: $v,
|
||||
date: $d,
|
||||
maturity: $m,
|
||||
summary: "",
|
||||
notes: ""
|
||||
}] + .)
|
||||
' changelog.json > "$tmp_changelog"
|
||||
fi
|
||||
|
||||
mv "$tmp_changelog" changelog.json
|
||||
|
||||
# -------------------------------------------------
|
||||
# Regenerate Markdown changelog
|
||||
# -------------------------------------------------
|
||||
./tools/genmd-changelog.sh
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
#!/usr/bin/env bash
|
||||
# genmd-changelog.sh
|
||||
#
|
||||
# Generate docs/CHANGELOG.md from changelog.json, metadata.json,
|
||||
# and Git merge history.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
OUT_FILE="docs/CHANGELOG.md"
|
||||
|
||||
echo "Generating ${OUT_FILE}"
|
||||
|
||||
# -------------------------------------------------
|
||||
# Load metadata for header
|
||||
# -------------------------------------------------
|
||||
NAME=$(jq -r '.METADATA.description' metadata.json)
|
||||
ABBR=$(jq -r '.METADATA.abbr' metadata.json)
|
||||
VERSION=$(jq -r '.METADATA.version' metadata.json)
|
||||
DATE=$(jq -r '.METADATA.version_date' metadata.json)
|
||||
AUTHOR=$(jq -r '.METADATA.author' metadata.json)
|
||||
COPYRIGHT=$(jq -r '.METADATA.copyright' metadata.json)
|
||||
LICENSE=$(jq -r '.METADATA.license' metadata.json)
|
||||
|
||||
# -------------------------------------------------
|
||||
# Collect release merge commits (newest → oldest)
|
||||
# -------------------------------------------------
|
||||
mapfile -t MERGES < <(
|
||||
git log main --merges --pretty=format:'%H|%s' |
|
||||
grep -E "(Merge branch 'v|Release v)"
|
||||
)
|
||||
|
||||
find_merge() {
|
||||
local tag="v$1"
|
||||
for entry in "${MERGES[@]}"; do
|
||||
if [[ "$entry" == *"Merge branch '${tag}'"* ]] || [[ "$entry" == *"Release ${tag}"* ]]; then
|
||||
echo "${entry%%|*}"
|
||||
return
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# -------------------------------------------------
|
||||
# Write header (README-style)
|
||||
# -------------------------------------------------
|
||||
{
|
||||
echo "# ${NAME} (${ABBR})"
|
||||
echo ""
|
||||
echo "| Attribute | Value |"
|
||||
echo "| --- | --- |"
|
||||
echo "| **Author** | ${AUTHOR} |"
|
||||
echo "| **Copyright** | ${COPYRIGHT} |"
|
||||
echo "| **License** | ${LICENSE} |"
|
||||
echo "| **Version** | ${VERSION} |"
|
||||
echo "| **Date** | ${DATE} |"
|
||||
echo ""
|
||||
} > "$OUT_FILE"
|
||||
|
||||
# -------------------------------------------------
|
||||
# Iterate releases (SEMVER newest → oldest)
|
||||
# -------------------------------------------------
|
||||
jq -r '
|
||||
def semver:
|
||||
(.version | split(".") | map(tonumber));
|
||||
|
||||
.releases
|
||||
| sort_by(semver)
|
||||
| reverse
|
||||
| .[]
|
||||
| "\(.version)|\(.date)|\(.maturity)|\(.summary)|\(.notes)"
|
||||
' changelog.json |
|
||||
while IFS='|' read -r version rdate maturity summary notes; do
|
||||
tag="v${version}"
|
||||
merge_commit=$(find_merge "$version")
|
||||
|
||||
echo "## ${tag} - ${rdate} (${maturity})" >> "$OUT_FILE"
|
||||
echo "" >> "$OUT_FILE"
|
||||
|
||||
if [[ -n "$summary" ]]; then
|
||||
echo "**Summary**" >> "$OUT_FILE"
|
||||
echo "$summary" >> "$OUT_FILE"
|
||||
echo "" >> "$OUT_FILE"
|
||||
fi
|
||||
|
||||
if [[ -n "$notes" ]]; then
|
||||
echo "**Notes**" >> "$OUT_FILE"
|
||||
echo "$notes" >> "$OUT_FILE"
|
||||
echo "" >> "$OUT_FILE"
|
||||
fi
|
||||
|
||||
# -------------------------------------------------
|
||||
# Commit list
|
||||
# -------------------------------------------------
|
||||
if [[ -n "$merge_commit" ]]; then
|
||||
# Released version: merge-boundary commits
|
||||
lower_bound=""
|
||||
for ((i=0; i<${#MERGES[@]}; i++)); do
|
||||
if [[ "${MERGES[$i]}" == "${merge_commit}"* ]]; then
|
||||
if (( i + 1 < ${#MERGES[@]} )); then
|
||||
lower_bound="${MERGES[$((i+1))]%%|*}"
|
||||
fi
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -n "$lower_bound" ]]; then
|
||||
git log "${lower_bound}..${merge_commit}" \
|
||||
--no-merges \
|
||||
--pretty=format:"- %s" >> "$OUT_FILE"
|
||||
else
|
||||
git log "${merge_commit}" \
|
||||
--no-merges \
|
||||
--pretty=format:"- %s" >> "$OUT_FILE"
|
||||
fi
|
||||
else
|
||||
# Active (not yet merged) version: commits since last release
|
||||
if [[ ${#MERGES[@]} -gt 0 ]]; then
|
||||
last_merge="${MERGES[0]%%|*}"
|
||||
git log "${last_merge}..HEAD" \
|
||||
--no-merges \
|
||||
--pretty=format:"- %s" >> "$OUT_FILE"
|
||||
else
|
||||
git log HEAD \
|
||||
--no-merges \
|
||||
--pretty=format:"- %s" >> "$OUT_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "" >> "$OUT_FILE"
|
||||
echo "" >> "$OUT_FILE"
|
||||
done
|
||||
|
||||
cp docs/CHANGELOG.md sdl-mgr/html/md/changelog.md
|
||||
|
||||
echo "Changelog written to ${OUT_FILE}"
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
#!/usr/bin/env bash
|
||||
# main-merge.sh - Merge release branch into main, tag, and delete branch
|
||||
set -euo pipefail
|
||||
|
||||
CUR_VER=$(jq -r '.METADATA.version' metadata.json)
|
||||
CUR_BRANCH=$(git branch --show-current)
|
||||
TAG="v${CUR_VER}"
|
||||
|
||||
# Ensure correct branch
|
||||
if [[ "$CUR_BRANCH" != "$TAG" ]]; then
|
||||
echo "You must be on branch $TAG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure clean working tree
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
echo "There are uncommitted changes"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure tag does not already exist
|
||||
if git show-ref --verify --quiet "refs/tags/$TAG"; then
|
||||
echo "Tag $TAG already exists"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Switch to main
|
||||
git checkout main
|
||||
if [[ "$(git branch --show-current)" != "main" ]]; then
|
||||
echo "Failed to switch to main"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Merge release branch
|
||||
git merge --no-ff "$CUR_BRANCH" -m "Release $TAG"
|
||||
|
||||
# Create annotated tag
|
||||
git tag -a "$TAG" -m "Release $TAG"
|
||||
|
||||
# Delete release branch
|
||||
git branch -d "$CUR_BRANCH"
|
||||
|
||||
echo "Release $TAG merged, tagged, and branch deleted"
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
#!/usr/bin/env bash
|
||||
# metadata-update.sh - Update SDL metadata in package.json and Markdown files
|
||||
set -euo pipefail
|
||||
|
||||
# -----------------------------
|
||||
# Resolve paths
|
||||
# -----------------------------
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SDL_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
META_FILE="$SDL_ROOT/metadata.json"
|
||||
|
||||
command -v jq >/dev/null || { echo "jq required"; exit 1; }
|
||||
command -v sed >/dev/null || { echo "sed required"; exit 1; }
|
||||
|
||||
# -----------------------------
|
||||
# Helpers
|
||||
# -----------------------------
|
||||
get_meta() {
|
||||
jq -r ".METADATA.$1" "$META_FILE"
|
||||
}
|
||||
|
||||
escape_regex() {
|
||||
printf '%s' "$1" | sed -e 's/[][\/.^$*+?{}|()]/\\&/g'
|
||||
}
|
||||
|
||||
# -----------------------------
|
||||
# Update package.json
|
||||
# -----------------------------
|
||||
update_package_json() {
|
||||
local file="$1"
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
|
||||
jq \
|
||||
--argfile meta "$META_FILE" \
|
||||
'
|
||||
. as $pkg
|
||||
| ($meta.META_MAPS["package.json"] | to_entries) as $maps
|
||||
| reduce $maps[] as $m ($pkg;
|
||||
.[$m.key] = $meta.METADATA[$m.value]
|
||||
)
|
||||
' "$file" > "$tmp"
|
||||
|
||||
mv "$tmp" "$file"
|
||||
echo "Updated $file"
|
||||
}
|
||||
|
||||
# -----------------------------
|
||||
# Update Markdown metadata table
|
||||
# -----------------------------
|
||||
update_markdown() {
|
||||
local file="$1"
|
||||
|
||||
jq -r '.META_MAPS["*.md"] | to_entries[] | "\(.key)|\(.value)"' "$META_FILE" |
|
||||
while IFS='|' read -r label meta_key; do
|
||||
value="$(get_meta "$meta_key")"
|
||||
esc_label="$(escape_regex "$label")"
|
||||
|
||||
sed -i -E \
|
||||
"s#^\\|[[:space:]]*${esc_label}[[:space:]]*\\|[[:space:]]*[^|]*\\|#| ${label} | $value |#" \
|
||||
"$file"
|
||||
done
|
||||
|
||||
echo "Updated $file"
|
||||
}
|
||||
|
||||
# -----------------------------
|
||||
# Main
|
||||
# -----------------------------
|
||||
jq -r '.MANIFEST[]' "$META_FILE" | while read -r rel_path; do
|
||||
file="$SDL_ROOT/$rel_path"
|
||||
|
||||
if [[ ! -f "$file" ]]; then
|
||||
echo "WARN: $rel_path not found, skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
case "$(basename "$file")" in
|
||||
package.json)
|
||||
update_package_json "$file"
|
||||
;;
|
||||
*.md)
|
||||
update_markdown "$file"
|
||||
;;
|
||||
*)
|
||||
echo "WARN: No handler for $rel_path"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "Metadata update complete"
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
#!/usr/bin/env bash
|
||||
# new-version.sh - Create new release branch and update metadata
|
||||
set -euo pipefail
|
||||
|
||||
# -------------------------------------------------
|
||||
# Preconditions
|
||||
# -------------------------------------------------
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
echo "Working tree is not clean"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# -------------------------------------------------
|
||||
# Helpers
|
||||
# -------------------------------------------------
|
||||
suggest_next_version() {
|
||||
local cur
|
||||
cur=$(jq -r '.METADATA.version' metadata.json)
|
||||
IFS='.' read -ra parts <<< "$cur"
|
||||
echo "${parts[0]}.${parts[1]}.$((parts[2] + 1))"
|
||||
}
|
||||
|
||||
# -------------------------------------------------
|
||||
# Load metadata
|
||||
# -------------------------------------------------
|
||||
DESC=$(jq -r '.METADATA.description' metadata.json)
|
||||
ABBR=$(jq -r '.METADATA.abbr' metadata.json)
|
||||
COPYRT=$(jq -r '.METADATA.copyright' metadata.json)
|
||||
CUR_VER=$(jq -r '.METADATA.version' metadata.json)
|
||||
CUR_VER_DATE=$(jq -r '.METADATA.version_date' metadata.json)
|
||||
NXT_VER=$(suggest_next_version)
|
||||
|
||||
echo "Description: $DESC ($ABBR)"
|
||||
echo "Copyright: (C) $COPYRT"
|
||||
echo "Current version: $CUR_VER"
|
||||
echo "Current version date: $CUR_VER_DATE"
|
||||
echo ""
|
||||
|
||||
read -p "New version: [$NXT_VER]: " NEW_VER
|
||||
if [[ -z "$NEW_VER" ]]; then
|
||||
NEW_VER="$NXT_VER"
|
||||
fi
|
||||
|
||||
NEW_VER_DATE=$(date +%Y-%m-%d)
|
||||
|
||||
echo ""
|
||||
echo "New version: $NEW_VER"
|
||||
echo "New version date: $NEW_VER_DATE"
|
||||
echo ""
|
||||
|
||||
read -p "Are you sure? (Y/n) " -n 1 -r
|
||||
echo ""
|
||||
if [[ $REPLY =~ ^[Nn]$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# -------------------------------------------------
|
||||
# Safety checks
|
||||
# -------------------------------------------------
|
||||
if git show-ref --verify --quiet "refs/heads/v${NEW_VER}"; then
|
||||
echo "Branch v${NEW_VER} already exists"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# -------------------------------------------------
|
||||
# Update metadata.json (structurally)
|
||||
# -------------------------------------------------
|
||||
tmp_meta=$(mktemp)
|
||||
trap 'rm -f "$tmp_meta"' EXIT
|
||||
|
||||
jq --arg v "$NEW_VER" --arg d "$NEW_VER_DATE" '
|
||||
.METADATA.version = $v
|
||||
| .METADATA.version_date = $d
|
||||
' metadata.json > "$tmp_meta"
|
||||
|
||||
mv "$tmp_meta" metadata.json
|
||||
|
||||
# -------------------------------------------------
|
||||
# Create branch
|
||||
# -------------------------------------------------
|
||||
git checkout -b "v${NEW_VER}"
|
||||
|
||||
# -------------------------------------------------
|
||||
# Propagate metadata and commit
|
||||
# -------------------------------------------------
|
||||
./tools/metadata-update.sh
|
||||
|
||||
git commit -am "Start v${NEW_VER}"
|
||||
|
||||
echo "Created new release branch v${NEW_VER}"
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import socket
|
||||
import sys
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: python udp_listener.py <port>")
|
||||
return
|
||||
|
||||
port = int(sys.argv[1])
|
||||
|
||||
# Create a datagram socket
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
except socket.error as msg:
|
||||
print('Failed to create socket. ' + str(msg))
|
||||
return
|
||||
|
||||
# Enable broadcasting mode
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
|
||||
try:
|
||||
# Bind the socket to a specific port
|
||||
sock.bind(('', port))
|
||||
except socket.error as msg:
|
||||
print('Bind failed. ' + str(msg))
|
||||
return
|
||||
|
||||
while True:
|
||||
data, addr = sock.recvfrom(1024)
|
||||
print(data.decode())
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in New Issue