Compare commits

..

30 Commits

Author SHA1 Message Date
cosmistack-bot
5510531c21 chore(release): 1.30.0-rc.1 [skip ci] 2026-03-20 06:06:12 +00:00
Jake Turner
456a6d33b1
docs: updated release notes with latest changes 2026-03-20 06:03:23 +00:00
Jake Turner
2ee022baa2
docs: additional comments in management_compose about storage config 2026-03-20 06:02:54 +00:00
Jake Turner
128440806c
ops: added additional warning about possible overwrites of existing custom installs 2026-03-20 05:55:14 +00:00
Jake Turner
cd331b544a
ops: added a check for docker-compose version in Nomad utility scripts 2026-03-20 05:48:07 +00:00
Jake Turner
adb3357eb1
docs: add note about Dozzle optionality 2026-03-20 04:12:35 +00:00
Jake Turner
3ede27aa47
docs: improve docs for advanced install 2026-03-20 04:09:27 +00:00
Jake Turner
9eeec9a8f9
fix(Docker): ensure fresh GPU detection when Ollama ctr updated 2026-03-20 02:38:31 +00:00
Chris Sherwood
5314c793de fix: improve download reliability with stall detection, failure visibility, and Wikipedia status tracking
Three bugs caused downloads to hang, disappear, or leave stuck spinners:
1. Wikipedia downloads that failed never updated the DB status from 'downloading',
   leaving the spinner stuck forever. Now the worker's failed handler marks them as failed.
2. No stall detection on streaming downloads - if data stopped flowing mid-download,
   the job hung indefinitely. Added a 5-minute stall timer that triggers retry.
3. Failed jobs were invisible to users since only waiting/active/delayed states were
   queried. Now failed jobs appear with error indicators in the download list.

Closes #364, closes #216

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 19:25:25 -07:00
Jake Turner
c64fe74494 fix(maps): remove DC from South Atlantic until generated 2026-03-19 19:20:32 -07:00
Chris Sherwood
ee8763d746 fix(maps): split combined Indiana/Michigan entry into separate states
The East North Central region had a single "indianamichigan" entry pointing
to a pmtiles file that doesn't exist. Indiana and Michigan are separate
files in the maps repo.

Closes #350

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 19:20:32 -07:00
Chris Sherwood
303c1e93f0 fix(collections): update stale React devdocs ZIM URL
Kiwix skipped the January 2026 build of devdocs_en_react — the
2026-01 URL returns 404. Updated to 2026-02 which exists.

Closes #269

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 19:14:07 -07:00
Jake Turner
d191a4fd36
feat: make Nomad fully composable 2026-03-20 02:11:48 +00:00
Andrew Barnes
6064908bf7 fix: prefer real block devices over tmpfs for storage display
The disk-collector could produce an empty fsSize array when
/host/proc/1/mounts is unreadable, causing the admin UI to fall back
to systeminformation's fsSize which includes tmpfs mounts. This led to
the storage display showing ~1.5 GB (tmpfs /run) instead of the actual
storage capacity.

Two changes:
- disk-collector: fall back to df on /storage when host mount table
  yields no real filesystems, since /storage is always bind-mounted
  from the host and reflects the actual backing device.
- easy-setup UI: when falling back to systeminformation fsSize, filter
  for /dev/ block devices and prefer the largest one instead of blindly
  taking the first entry.

Fixes #373
2026-03-19 17:21:31 -07:00
Jake Turner
e4d6ca4a48
build: change compose to use prebuilt sidecar-updater image 2026-03-19 23:22:00 +00:00
Jake Turner
27f766809b
fix(UI): minor styling fixes for Night Ops 2026-03-19 23:19:18 +00:00
orbisai0security
d709e7ee40
fix: upgrade systeminformation to 5.31.0 (CVE-2026-26318)
systeminformation: systeminformation: Arbitrary code execution via unsanitized `locate` output
Resolves CVE-2026-26318
2026-03-19 23:19:18 +00:00
dependabot[bot]
cf61d7e302
build(deps): bump fast-xml-parser from 5.3.8 to 5.5.6 in /admin
Bumps [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) from 5.3.8 to 5.5.6.
- [Release notes](https://github.com/NaturalIntelligence/fast-xml-parser/releases)
- [Changelog](https://github.com/NaturalIntelligence/fast-xml-parser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.3.8...v5.5.6)

---
updated-dependencies:
- dependency-name: fast-xml-parser
  dependency-version: 5.5.6
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-19 23:19:18 +00:00
Chris Sherwood
ba1bcb33fa
fix: prevent embedding retry storm when Ollama is not installed
When Ollama isn't installed, every ZIM download dispatches embedding jobs
that fail and retry 30x with 60s backoff. With many ZIM files downloading
in parallel, this exhausts Redis connections with EPIPE/ECONNRESET errors.

Two changes:
1. Don't dispatch embedding jobs when Ollama isn't installed (belt)
2. Use BullMQ UnrecoverableError for "not installed" so jobs fail
   immediately without retrying (suspenders)

Closes #351

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 23:19:18 +00:00
dependabot[bot]
ec0d30d788
build(deps): bump undici in /admin
Bumps  and [undici](https://github.com/nodejs/undici). These dependencies needed to be updated together.

Updates `undici` from 6.23.0 to 6.24.1
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.23.0...v6.24.1)

Updates `undici` from 7.20.0 to 7.24.3
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.23.0...v6.24.1)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 6.24.1
  dependency-type: indirect
- dependency-name: undici
  dependency-version: 7.24.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-19 23:19:18 +00:00
dependabot[bot]
3779553754
build(deps): bump tar from 7.5.10 to 7.5.11 in /admin
Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.10 to 7.5.11.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.10...v7.5.11)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.11
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-19 23:19:18 +00:00
Chris Sherwood
a24bd62df8
fix: default LOG_LEVEL to info in production
Debug logging in production is unnecessarily noisy. Users who need
debug output can still set LOG_LEVEL=debug in their compose.yml.

Closes #285

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 23:19:17 +00:00
Chris Sherwood
5c47fa39b7
feat(UI): add Debug Info modal for bug reporting
Add a "Debug Info" link to the footer and settings sidebar that opens a
modal with non-sensitive system information (version, OS, hardware, GPU,
installed services, internet status, update availability). Users can copy
the formatted text and paste it into GitHub issues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 23:19:17 +00:00
Chris Sherwood
5e18b00a2c
docs: update hardware price ranges to reflect 2026 market
Updated hardware guide price references from $200–$800+ to $150–$1,000+
based on community leaderboard data (41 submissions) and current market
pricing. DDR5 RAM and GPU prices are significantly inflated — budget DDR4
refurbs start at $150, recommended AMD APU builds run $500–$800, and
dedicated GPU builds start at $1,000+. Also noted AMD Ryzen 7 with
Radeon graphics as the community sweet spot in the FAQ.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 23:19:17 +00:00
Chris Sherwood
a6e37526a0
fix(security): remove MySQL and Redis port exposure to host
MySQL (3306) and Redis (6379) were published to all host interfaces
despite only being accessed by the admin container via Docker's internal
network. Redis has no authentication, so anyone on the LAN could connect.

Removes the port mappings — containers still communicate internally via
Docker service names.

Closes #279

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 23:19:17 +00:00
Jake Turner
78455ccc40
fix(maps): respect request protocol for reverse proxy HTTPS support 2026-03-19 23:19:17 +00:00
Chris Sherwood
2cc0ab2feb
fix(security): also disable Dozzle container actions
Dozzle runs on port 9999 with no authentication. DOZZLE_ENABLE_ACTIONS
allows anyone on the LAN to stop/restart containers. NOMAD already
handles container management through its own admin UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 23:19:17 +00:00
Chris Sherwood
2a8f833d65
fix(security): disable Dozzle web shell access
Dozzle's DOZZLE_ENABLE_SHELL=true on an unauthenticated port allows
anyone on the LAN to open a shell into containers, including nomad_admin
which has the Docker socket mounted — creating a path to host root.

Disables shell access while keeping log viewing and container actions
(restart/stop) enabled.

Closes #278

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 23:19:17 +00:00
Chris Sherwood
e847c6b3d0
feat(UI): add Support the Project settings page
Adds a new settings page with Ko-fi donation link, Rogue Support
banner, and community contribution options (GitHub, Discord).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 23:19:17 +00:00
Chris Sherwood
4db69d2173
feat(UI): add Night Ops dark mode with theme toggle
Add a warm charcoal dark mode ("Night Ops") using CSS variable swapping
under [data-theme="dark"]. All 23 desert palette variables are overridden
with dark-mode counterparts, and ~313 generic Tailwind classes (bg-white,
text-gray-*, border-gray-*) are replaced with semantic tokens.

Infrastructure:
- CSS variable overrides in app.css for both themes
- ThemeProvider + useTheme hook (localStorage + KV store sync)
- ThemeToggle component (moon/sun icons, "Night Ops"/"Day Ops" labels)
- FOUC prevention script in inertia_layout.edge
- Toggle placed in StyledSidebar and Footer for access on every page

Color replacements across 50 files:
- bg-white → bg-surface-primary
- bg-gray-50/100 → bg-surface-secondary
- text-gray-900/800 → text-text-primary
- text-gray-600/500 → text-text-secondary/text-text-muted
- border-gray-200/300 → border-border-subtle/border-border-default
- text-desert-white → text-white (fixes invisible text on colored bg)
- Button hover/active states use dedicated btn-green-hover/active vars

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 23:19:17 +00:00
49 changed files with 569 additions and 645 deletions

View File

@ -1,25 +0,0 @@
name: Build Admin
on: pull_request
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'npm'
- name: Install dependencies
run: npm ci
working-directory: ./admin
- name: Run build
run: npm run build
working-directory: ./admin

View File

@ -33,7 +33,7 @@ jobs:
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v2
with:

View File

@ -33,7 +33,7 @@ jobs:
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v2
with:

View File

@ -33,7 +33,7 @@ jobs:
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v2
with:

View File

@ -22,7 +22,7 @@ jobs:
newVersion: ${{ steps.semver.outputs.new_release_version }}
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false

View File

@ -1,58 +0,0 @@
name: Validate Collection URLs
on:
push:
paths:
- 'collections/**.json'
pull_request:
paths:
- 'collections/**.json'
jobs:
validate-urls:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Extract and validate URLs
run: |
FAILED=0
CHECKED=0
FAILED_URLS=""
# Recursively extract all non-null string URLs from every JSON file in collections/
URLS=$(jq -r '.. | .url? | select(type == "string")' collections/*.json | sort -u)
while IFS= read -r url; do
[ -z "$url" ] && continue
CHECKED=$((CHECKED + 1))
printf "Checking: %s ... " "$url"
# Use Range: bytes=0-0 to avoid downloading the full file.
# --max-filesize 1 aborts early if the server ignores the Range header
# and returns 200 with the full body. The HTTP status is still captured.
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
--range 0-0 \
--max-filesize 1 \
--max-time 30 \
--location \
"$url")
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "206" ]; then
echo "OK ($HTTP_CODE)"
else
echo "FAILED ($HTTP_CODE)"
FAILED=$((FAILED + 1))
FAILED_URLS="$FAILED_URLS\n - $url (HTTP $HTTP_CODE)"
fi
done <<< "$URLS"
echo ""
echo "Checked $CHECKED URLs, $FAILED failed."
if [ "$FAILED" -gt 0 ]; then
echo ""
echo "Broken URLs:"
printf "%b\n" "$FAILED_URLS"
exit 1
fi

100
FAQ.md
View File

@ -1,100 +0,0 @@
# Frequently Asked Questions (FAQ)
Find answers to some of the most common questions about Project N.O.M.A.D.
## Can I customize the port(s) that NOMAD uses?
Yes, you can customize the ports that NOMAD's core services (Command Center, MySQL, Redis) use. Please refer to the [Advanced Installation](README.md#advanced-installation) section of the README for more details on how to do this.
Note: As of 3/24/2026, only the core services defined in the `docker-compose.yml` file currently support port customization - the installable applications (e.g. Ollama, Kiwix, etc.) do not yet support this, but we have multiple PR's in the works to add this feature for all installable applications in a future release.
## Can I customize the storage location for NOMAD's data?
Yes, you can customize the storage location for NOMAD's content by modifying the `docker-compose.yml` file to adjust the appropriate bind mounts to point to your desired storage location on your host machine. Please refer to the [Advanced Installation](README.md#advanced-installation) section of the README for more details on how to do this.
## Can I run NOMAD on MAC, WSL2, or a non-Debian-based Distro?
See [Why does NOMAD require a Debian-based OS?](#why-does-nomad-require-a-debian-based-os)
## Why does NOMAD require a Debian-based OS?
Project N.O.M.A.D. is currently designed to run on Debian-based Linux distributions (with Ubuntu being the recommended distro) because our installation scripts and Docker configurations are optimized for this environment. While it's technically possible to run the Docker containers on other operating systems that support Docker, we have not tested or optimized the installation process for non-Debian-based systems, so we cannot guarantee a smooth experience on those platforms at this time.
Support for other operating systems will come in the future, but because our development resources are limited as a free and open-source project, we needed to prioritize our efforts and focus on a narrower set of supported platforms for the initial release. We chose Debian-based Linux as our starting point because it's widely used, easy to spin up, and provides a stable environment for running Docker containers.
Community members have provided guides for running N.O.M.A.D. on other platforms (e.g. WSL2, Mac, etc.) in our Discord community and [Github Discussions](https://github.com/Crosstalk-Solutions/project-nomad/discussions), so if you're interested in running N.O.M.A.D. on a non-Debian-based system, we recommend checking there for any available resources or guides. However, keep in mind that if you choose to run N.O.M.A.D. on a non-Debian-based system, you may encounter issues that we won't be able to provide support for, and you may need to have a higher level of technical expertise to troubleshoot and resolve any problems that arise.
## Can I run NOMAD on a Raspberry Pi or other ARM-based device?
Project N.O.M.A.D. is currently designed to run on x86-64 architecture, and we have not yet tested or optimized it for ARM-based devices like the Raspberry Pi (and have not published any official images for ARM architecture).
Support for ARM-based devices is on our roadmap, but our initial focus was on x86-64 hardware due to its widespread use and compatibility with a wide range of applications.
Community members have forked and published their own ARM-compatible images and installation guides for running N.O.M.A.D. on Raspberry Pi and other ARM-based devices in our Discord community and [Github Discussions](https://github.com/Crosstalk-Solutions/project-nomad/discussions), but these are not officially supported by the core development team, and we cannot guarantee their functionality or provide support for any issues that arise when using these community-created resources.
## What are the hardware requirements for running NOMAD?
Project N.O.M.A.D. itself is quite lightweight and can run on even modest x86-64 hardware, but the tools and resources you choose to install with N.O.M.A.D. will determine the specs required for your unique deployment. Please see the [Hardware Guide](https://www.projectnomad.us/hardware) for detailed build recommendations at various price points.
## Does NOMAD support languages other than English?
As of March 2026, Project N.O.M.A.D.'s UI is only available in English, and the majority of the tools and resources available through N.O.M.A.D. are also primarily in English. However, we have multi-language support on our roadmap for a future release, and we are actively working on adding support for additional languages both in the UI and in the available tools/resources. If you're interested in contributing to this effort, please check out our [CONTRIBUTING.md](CONTRIBUTING.md) file for guidelines on how to get involved.
## What technologies is NOMAD built with?
Project N.O.M.A.D. is built using a combination of technologies, including:
- **Docker:** for containerization of the Command Center and its dependencies
- **Node.js & TypeScript:** for the backend of the Command Center, particularly the [AdonisJS](https://adonisjs.com/) framework
- **React:** for the frontend of the Command Center, utilizing [Vite](https://vitejs.dev/) and [Inertia.js](https://inertiajs.com/) under the hood
- **MySQL:** for the Command Center's database
- **Redis:** for various caching, background jobs, "cron" tasks, and other internal processes within the Command Center
NOMAD makes use of the Docker-outside-of-Docker ("DooD") pattern, which allows the Command Center to manage and orchestrate other Docker containers on the host machine without needing to run Docker itself inside a container. This approach provides better performance and compatibility with a wider range of host environments while still allowing for powerful container management capabilities through the Command Center's UI.
## Can I run NOMAD if I have existing Docker containers on my machine?
Yes, you can safely run Project N.O.M.A.D. on a machine that already has existing Docker containers. NOMAD is designed to coexist with other Docker containers and will not interfere with them as long as there are no port conflicts or resource constraints.
All of NOMAD's containers are prefixed with `nomad_` in their names, so they can be easily identified and managed separately from any other containers you may have running. Just make sure to review the ports that NOMAD's core services (Command Center, MySQL, Redis) use during installation and adjust them if necessary to avoid conflicts with your existing containers.
## Why does NOMAD require access to the Docker socket?
See [What technologies is NOMAD built with?](#what-technologies-is-nomad-built-with)
## Can I use any AI models?
NOMAD by default uses Ollama inside of a docker container to run LLM Models for the AI Assistant. So if you find a model on HuggingFace for example, you won't be able to use that model in NOMAD. The list of available models in the AI Assistant settings (/settings/models) may not show all of the models you are looking for. If you found a model from https://ollama.com/search that you'd like to try and its not in the settings page, you can use a curl command to download the model.
`curl -X POST -H "Content-Type: application/json" -d '{"model":"MODEL_NAME_HERE"}' http://localhost:8080/api/ollama/models` replacing MODEL_NAME_HERE with the model name from whats in the ollama website.
## Do I have to install the AI features in NOMAD?
No, the AI features in NOMAD (Ollama, Qdrant, custom RAG pipeline, etc.) are all optional and not required to use the core functionality of NOMAD.
## Is NOMAD actually free? Are there any hidden costs?
Yes, Project N.O.M.A.D. is completely free and open-source software licensed under the Apache License 2.0. There are no hidden costs or fees associated with using NOMAD itself, and we don't have any plans to introduce "premium" features or paid tiers.
Aside from the cost of the hardware you choose to run it on, there are no costs associated with using NOMAD.
## Do you sell hardware or pre-built devices with NOMAD pre-installed?
No, we do not sell hardware or pre-built devices with NOMAD pre-installed at this time. Project N.O.M.A.D. is a free and open-source software project, and we provide detailed installation instructions and hardware recommendations for users to set up their own NOMAD instances on compatible hardware of their choice. The tradeoff to this DIY approach is some additional setup time and technical know-how required on the user's end, but it also allows for greater flexibility and customization in terms of hardware selection and configuration to best suit each user's unique needs, budget, and preferences.
## How quickly are issues resolved when reported?
We strive to address and resolve issues as quickly as possible, but please keep in mind that Project N.O.M.A.D. is a free and open-source project maintained by a small team of volunteers. We prioritize issues based on their severity, impact on users, and the resources required to resolve them. Critical issues that affect a large number of users are typically addressed more quickly, while less severe issues may take longer to resolve. Aside from the development efforts needed to address the issue, we do our best to conduct thorough testing and validation to ensure that any fix we implement doesn't introduce new issues or regressions, which also adds to the time it takes to resolve an issue.
We also encourage community involvement in troubleshooting and resolving issues, so if you encounter a problem, please consider checking our Discord community and Github Discussions for potential solutions or workarounds while we work on an official fix.
## How often are new features added or updates released?
We aim to release updates and new features on a regular basis, but the exact timing can vary based on the complexity of the features being developed, the resources available to our volunteer development team, and the feedback and needs of our community. We typically release smaller "patch" versions more frequently to address bugs and make minor improvements, while larger feature releases may take more time to develop and test before they're ready for release.
## I opened a PR to contribute a new feature or fix a bug. How long does it usually take for PRs to be reviewed and merged?
We appreciate all contributions to the project and strive to review and merge pull requests (PRs) as quickly as possible. The time it takes for a PR to be reviewed and merged can vary based on several factors, including the complexity of the changes, the current workload of our maintainers, and the need for any additional testing or revisions.
Because NOMAD is still a young project, some PRs (particularly those for new features) may take longer to review and merge as we prioritize building out the core functionality and ensuring stability before adding new features. However, we do our best to provide timely feedback on all PRs and keep contributors informed about the status of their contributions.
## I have a question that isn't answered here. Where can I ask for help?
If you have a question that isn't answered in this FAQ, please feel free to ask for help in our Discord community (https://discord.com/invite/crosstalksolutions) or on our Github Discussions page (https://github.com/Crosstalk-Solutions/project-nomad/discussions).
## I have a suggestion for a new feature or improvement. How can I share it?
We welcome and encourage suggestions for new features and improvements! We highly encourage sharing your ideas (or upvoting existing suggestions) on our public roadmap at https://roadmap.projectnomad.us, where we track new feature requests. This is the best way to ensure that your suggestion is seen by the development team and the community, and it also allows other community members to upvote and show support for your idea, which can help prioritize it for future development.

View File

@ -21,17 +21,15 @@ Project N.O.M.A.D. can be installed on any Debian-based operating system (we rec
*Note: sudo/root privileges are required to run the install script*
### Quick Install (Debian-based OS Only)
#### Quick Install (Debian-based OS Only)
```bash
sudo apt-get update && sudo apt-get install -y curl && curl -fsSL https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/install_nomad.sh -o install_nomad.sh && sudo bash install_nomad.sh
```
Project N.O.M.A.D. is now installed on your device! Open a browser and navigate to `http://localhost:8080` (or `http://DEVICE_IP:8080`) to start exploring!
For a complete step-by-step walkthrough (including Ubuntu installation), see the [Installation Guide](https://www.projectnomad.us/install).
### Advanced Installation
For more control over the installation process, copy and paste the [Docker Compose template](https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/management_compose.yaml) into a `docker-compose.yml` file and customize it to your liking (be sure to replace any placeholders with your actual values). Then, run `docker compose up -d` to start the Command Center and its dependencies. Note: this method is recommended for advanced users only, as it requires familiarity with Docker and manual configuration before starting.
For more control over the installation process, copy and paste the [Docker Compose template](https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/management_compose.yml) into a `docker-compose.yml` file and customize it to your liking (be sure to replace any placeholders with your actual values). Then, run `docker compose up -d` to start the Command Center and its dependencies. Note: this method is recommended for advanced users only, as it requires familiarity with Docker and manual configuration before starting.
## How It Works
N.O.M.A.D. is a management UI ("Command Center") and API that orchestrates a collection of containerized tools and resources via [Docker](https://www.docker.com/). It handles installation, configuration, and updates for everything — so you don't have to.
@ -89,9 +87,6 @@ To run LLM's and other included AI tools:
Again, Project N.O.M.A.D. itself is quite lightweight - it's the tools and resources you choose to install with N.O.M.A.D. that will determine the specs required for your unique deployment
## Frequently Asked Questions (FAQ)
For answers to common questions about Project N.O.M.A.D., please see our [FAQ](FAQ.md) page.
## About Internet Usage & Privacy
Project N.O.M.A.D. is designed for offline usage. An internet connection is only required during the initial installation (to download dependencies) and if you (the user) decide to download additional tools and resources at a later time. Otherwise, N.O.M.A.D. does not require an internet connection and has ZERO built-in telemetry.
@ -100,20 +95,49 @@ To test internet connectivity, N.O.M.A.D. attempts to make a request to Cloudfla
## About Security
By design, Project N.O.M.A.D. is intended to be open and available without hurdles - it includes no authentication. If you decide to connect your device to a local network after install (e.g. for allowing other devices to access it's resources), you can block/open ports to control which services are exposed.
**Will authentication be added in the future?** Maybe. It's not currently a priority, but if there's enough demand for it, we may consider building in an optional authentication layer in a future release to support uses cases where multiple users need access to the same instance but with different permission levels (e.g. family use with parental controls, classroom use with teacher/admin accounts, etc.). We have a suggestion for this on our public roadmap, so if this is something you'd like to see, please upvote it here: https://roadmap.projectnomad.us/posts/1/user-authentication-please-build-in-user-auth-with-admin-user-roles
For now, we recommend using network-level controls to manage access if you're planning to expose your N.O.M.A.D. instance to other devices on a local network. N.O.M.A.D. is not designed to be exposed directly to the internet, and we strongly advise against doing so unless you really know what you're doing, have taken appropriate security measures, and understand the risks involved.
**Will authentication be added in the future?** Maybe. It's not currently a priority, but if there's enough demand for it, we may consider building in an optional authentication layer in a future release to support uses cases where multiple users need access to the same instance but with different permission levels (e.g. family use with parental controls, classroom use with teacher/admin accounts, etc.). For now, we recommend using network-level controls to manage access if you're planning to expose your N.O.M.A.D. instance to other devices on a local network. N.O.M.A.D. is not designed to be exposed directly to the internet, and we strongly advise against doing so unless you really know what you're doing, have taken appropriate security measures, and understand the risks involved.
## Contributing
Contributions are welcome and appreciated! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to contribute to the project.
Contributions are welcome and appreciated! Please read this section fully to understand how to contribute to the project.
### General Guidelines
- **Open an issue first**: Before starting work on a new feature or bug fix, please open an issue to discuss your proposed changes. This helps ensure that your contribution aligns with the project's goals and avoids duplicate work. Title the issue clearly and provide a detailed description of the problem or feature you want to work on.
- **Fork the repository**: Click the "Fork" button at the top right of the repository page to create a copy of the project under your GitHub account.
- **Create a new branch**: In your forked repository, create a new branch for your work. Use a descriptive name for the branch that reflects the purpose of your changes (e.g., `fix/issue-123` or `feature/add-new-tool`).
- **Make your changes**: Implement your changes in the new branch. Follow the existing code style and conventions used in the project. Be sure to test your changes locally to ensure they work as expected.
- **Add Release Notes**: If your changes include new features, bug fixes, or improvements, please see the "Release Notes" section below to properly document your contribution for the next release.
- **Conventional Commits**: When committing your changes, please use conventional commit messages to provide clear and consistent commit history. The format is `<type>(<scope>): <description>`, where:
- `type` is the type of change (e.g., `feat` for new features, `fix` for bug fixes, `docs` for documentation changes, etc.)
- `scope` is an optional area of the codebase that your change affects (e.g., `api`, `ui`, `docs`, etc.)
- `description` is a brief summary of the change
- **Submit a pull request**: Once your changes are ready, submit a pull request to the main repository. Provide a clear description of your changes and reference any related issues. The project maintainers will review your pull request and may provide feedback or request changes before it can be merged.
- **Be responsive to feedback**: If the maintainers request changes or provide feedback on your pull request, please respond in a timely manner. Stale pull requests may be closed if there is no activity for an extended period.
- **Follow the project's code of conduct**: Please adhere to the project's code of conduct when interacting with maintainers and other contributors. Be respectful and considerate in your communications.
- **No guarantee of acceptance**: The project is community-driven, and all contributions are appreciated, but acceptance is not guaranteed. The maintainers will evaluate each contribution based on its quality, relevance, and alignment with the project's goals.
- **Thank you for contributing to Project N.O.M.A.D.!** Your efforts help make this project better for everyone.
### Versioning
This project uses semantic versioning. The version is managed in the root `package.json`
and automatically updated by semantic-release. For simplicity's sake, the "project-nomad" image
uses the same version defined there instead of the version in `admin/package.json` (stays at 0.0.0), as it's the only published image derived from the code.
### Release Notes
Human-readable release notes live in [`admin/docs/release-notes.md`](admin/docs/release-notes.md) and are displayed in the Command Center's built-in documentation.
When working on changes, add a summary to the `## Unreleased` section at the top of that file under the appropriate heading:
- **Features** — new user-facing capabilities
- **Bug Fixes** — corrections to existing behavior
- **Improvements** — enhancements, refactors, docs, or dependency updates
Use the format `- **Area**: Description` to stay consistent with existing entries. When a release is triggered, CI automatically stamps the version and date, commits the update, and pushes the content to the GitHub release.
## Community & Resources
- **Website:** [www.projectnomad.us](https://www.projectnomad.us) - Learn more about the project
- **Discord:** [Join the Community](https://discord.com/invite/crosstalksolutions) - Get help, share your builds, and connect with other NOMAD users
- **Benchmark Leaderboard:** [benchmark.projectnomad.us](https://benchmark.projectnomad.us) - See how your hardware stacks up against other NOMAD builds
- **Troubleshooting Guide:** [TROUBLESHOOTING.md](TROUBLESHOOTING.md) - Find solutions to common issues
- **FAQ:** [FAQ.md](FAQ.md) - Find answers to frequently asked questions
## License

View File

@ -15,9 +15,4 @@ export default class DownloadsController {
const payload = await request.validateUsing(downloadJobsByFiletypeSchema)
return this.downloadService.listDownloadJobs(payload.params.filetype)
}
async removeJob({ params }: HttpContext) {
await this.downloadService.removeFailedJob(params.jobId)
return { success: true }
}
}

View File

@ -35,7 +35,7 @@ export default class SystemController {
if (result.success) {
response.send({ success: true, message: result.message });
} else {
response.status(400).send({ success: false, message: result.message });
response.status(400).send({ error: result.message });
}
}

View File

@ -1,4 +1,4 @@
import { Job, UnrecoverableError } from 'bullmq'
import { Job } from 'bullmq'
import { QueueService } from '#services/queue_service'
import { createHash } from 'crypto'
import logger from '@adonisjs/core/services/logger'
@ -63,10 +63,6 @@ export class DownloadModelJob {
logger.error(
`[DownloadModelJob] Failed to initiate download for model ${modelName}: ${result.message}`
)
// Don't retry errors that will never succeed (e.g., Ollama version too old)
if (result.retryable === false) {
throw new UnrecoverableError(result.message)
}
throw new Error(`Failed to initiate download for model: ${result.message}`)
}
@ -89,15 +85,6 @@ export class DownloadModelJob {
const queue = queueService.getQueue(this.queue)
const jobId = this.getJobId(params.modelName)
// Clear any previous failed job so a fresh attempt can be dispatched
const existing = await queue.getJob(jobId)
if (existing) {
const state = await existing.getState()
if (state === 'failed') {
await existing.remove()
}
}
try {
const job = await queue.add(this.key, params, {
jobId,
@ -117,9 +104,9 @@ export class DownloadModelJob {
}
} catch (error) {
if (error.message.includes('job already exists')) {
const active = await queue.getJob(jobId)
const existing = await queue.getJob(jobId)
return {
job: active,
job: existing,
created: false,
message: `Job already exists for model ${params.modelName}`,
}

View File

@ -571,10 +571,10 @@ export class BenchmarkService {
*/
private _normalizeScore(value: number, reference: number): number {
if (value <= 0) return 0
// Log scale with widened range: dividing log2 by 3 prevents scores from
// clamping to 0% for below-average hardware. Gives 50% at reference value.
// Log scale: score = 50 * (1 + log2(value/reference))
// This gives 50 at reference value, scales logarithmically
const ratio = value / reference
const score = 50 * (1 + Math.log2(Math.max(0.01, ratio)) / 3)
const score = 50 * (1 + Math.log2(Math.max(0.01, ratio)))
return Math.min(100, Math.max(0, score)) / 100
}
@ -583,9 +583,9 @@ export class BenchmarkService {
*/
private _normalizeScoreInverse(value: number, reference: number): number {
if (value <= 0) return 1
// Inverse: lower values = higher scores, with widened log range
// Inverse: lower values = higher scores
const ratio = reference / value
const score = 50 * (1 + Math.log2(Math.max(0.01, ratio)) / 3)
const score = 50 * (1 + Math.log2(Math.max(0.01, ratio)))
return Math.min(100, Math.max(0, score)) / 100
}
@ -619,7 +619,6 @@ export class BenchmarkService {
const eventsMatch = output.match(/events per second:\s*([\d.]+)/i)
const totalTimeMatch = output.match(/total time:\s*([\d.]+)s/i)
const totalEventsMatch = output.match(/total number of events:\s*(\d+)/i)
logger.debug(`[BenchmarkService] CPU output parsing - events/s: ${eventsMatch?.[1]}, total_time: ${totalTimeMatch?.[1]}, total_events: ${totalEventsMatch?.[1]}`)
return {
events_per_second: eventsMatch ? parseFloat(eventsMatch[1]) : 0,

View File

@ -615,8 +615,8 @@ export class DockerService {
* We'll download the lightweight mini Wikipedia Top 100 zim file for this purpose.
**/
const WIKIPEDIA_ZIM_URL =
'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/main/install/wikipedia_en_100_mini_2026-01.zim'
const filename = 'wikipedia_en_100_mini_2026-01.zim'
'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/main/install/wikipedia_en_100_mini_2025-06.zim'
const filename = 'wikipedia_en_100_mini_2025-06.zim'
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
logger.info(`[DockerService] Kiwix Serve pre-install: Downloading ZIM file to ${filepath}`)
@ -691,7 +691,6 @@ export class DockerService {
const runtimes = dockerInfo.Runtimes || {}
if ('nvidia' in runtimes) {
logger.info('[DockerService] NVIDIA container runtime detected via Docker API')
await this._persistGPUType('nvidia')
return { type: 'nvidia' }
}
} catch (error) {
@ -723,26 +722,12 @@ export class DockerService {
)
if (amdCheck.trim()) {
logger.info('[DockerService] AMD GPU detected via lspci')
await this._persistGPUType('amd')
return { type: 'amd' }
}
} catch (error) {
// lspci not available, continue
}
// Last resort: check if we previously detected a GPU and it's likely still present.
// This handles cases where live detection fails transiently (e.g., Docker daemon
// hiccup, runtime temporarily unavailable) but the hardware hasn't changed.
try {
const savedType = await KVStore.getValue('gpu.type')
if (savedType === 'nvidia' || savedType === 'amd') {
logger.info(`[DockerService] No GPU detected live, but KV store has '${savedType}' from previous detection. Using saved value.`)
return { type: savedType as 'nvidia' | 'amd' }
}
} catch {
// KV store not available, continue
}
logger.info('[DockerService] No GPU detected')
return { type: 'none' }
} catch (error) {
@ -751,15 +736,6 @@ export class DockerService {
}
}
private async _persistGPUType(type: 'nvidia' | 'amd'): Promise<void> {
try {
await KVStore.setValue('gpu.type', type)
logger.info(`[DockerService] Persisted GPU type '${type}' to KV store`)
} catch (error) {
logger.warn(`[DockerService] Failed to persist GPU type: ${error.message}`)
}
}
/**
* Discover AMD GPU DRI devices dynamically.
* Returns an array of device configurations for Docker.

View File

@ -50,15 +50,4 @@ export class DownloadService {
return b.progress - a.progress
})
}
async removeFailedJob(jobId: string): Promise<void> {
for (const queueName of [RunDownloadJob.queue, DownloadModelJob.queue]) {
const queue = this.queueService.getQueue(queueName)
const job = await queue.getJob(jobId)
if (job) {
await job.remove()
return
}
}
}
}

View File

@ -51,7 +51,7 @@ export class OllamaService {
* @param model Model name to download
* @returns Success status and message
*/
async downloadModel(model: string, progressCallback?: (percent: number) => void): Promise<{ success: boolean; message: string; retryable?: boolean }> {
async downloadModel(model: string, progressCallback?: (percent: number) => void): Promise<{ success: boolean; message: string }> {
try {
await this._ensureDependencies()
if (!this.ollama) {
@ -86,21 +86,11 @@ export class OllamaService {
logger.info(`[OllamaService] Model "${model}" downloaded successfully.`)
return { success: true, message: 'Model downloaded successfully.' }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
logger.error(
`[OllamaService] Failed to download model "${model}": ${errorMessage}`
`[OllamaService] Failed to download model "${model}": ${error instanceof Error ? error.message : error
}`
)
// Check for version mismatch (Ollama 412 response)
const isVersionMismatch = errorMessage.includes('newer version of Ollama')
const userMessage = isVersionMismatch
? 'This model requires a newer version of Ollama. Please update AI Assistant from the Apps page.'
: `Failed to download model: ${errorMessage}`
// Broadcast failure to connected clients so UI can show the error
this.broadcastDownloadError(model, userMessage)
return { success: false, message: userMessage, retryable: !isVersionMismatch }
return { success: false, message: 'Failed to download model.' }
}
}
@ -389,15 +379,6 @@ export class OllamaService {
return models
}
private broadcastDownloadError(model: string, error: string) {
transmit.broadcast(BROADCAST_CHANNELS.OLLAMA_MODEL_DOWNLOAD, {
model,
percent: -1,
error,
timestamp: new Date().toISOString(),
})
}
private broadcastDownloadProgress(model: string, percent: number) {
transmit.broadcast(BROADCAST_CHANNELS.OLLAMA_MODEL_DOWNLOAD, {
model,

View File

@ -579,21 +579,10 @@ export class SystemService {
return []
}
// Deduplicate: same device path mounted in multiple places (Docker bind-mounts)
// Keep the entry with the largest size — that's the real partition
const deduped = new Map<string, NomadDiskInfoRaw['fsSize'][0]>()
for (const entry of fsSize) {
const existing = deduped.get(entry.fs)
if (!existing || entry.size > existing.size) {
deduped.set(entry.fs, entry)
}
}
const dedupedFsSize = Array.from(deduped.values())
return diskLayout.blockdevices
.filter((disk) => disk.type === 'disk') // Only physical disks
.map((disk) => {
const filesystems = getAllFilesystems(disk, dedupedFsSize)
const filesystems = getAllFilesystems(disk, fsSize)
// Across all partitions
const totalUsed = filesystems.reduce((sum, p) => sum + (p.used || 0), 0)

View File

@ -138,13 +138,14 @@ export function matchesDevice(fsPath: string, deviceName: string): boolean {
// Remove /dev/ and /dev/mapper/ prefixes
const normalized = fsPath.replace('/dev/mapper/', '').replace('/dev/', '')
// Direct match (covers /dev/sda1 ↔ sda1, /dev/nvme0n1p1 ↔ nvme0n1p1)
// Direct match
if (normalized === deviceName) {
return true
}
// LVM/device-mapper: e.g., /dev/mapper/ubuntu--vg-ubuntu--lv contains "ubuntu--lv"
if (fsPath.startsWith('/dev/mapper/') && fsPath.includes(deviceName)) {
// LVM volumes use dashes instead of slashes
// e.g., ubuntu--vg-ubuntu--lv matches the device name
if (fsPath.includes(deviceName)) {
return true
}

View File

@ -3,7 +3,7 @@ import { defineConfig } from '@adonisjs/transmit'
import { redis } from '@adonisjs/transmit/transports'
export default defineConfig({
pingInterval: '30s',
pingInterval: false,
transport: {
driver: redis({
host: env.get('REDIS_HOST'),

View File

@ -70,7 +70,7 @@ export default class ServiceSeeder extends BaseSeeder {
display_order: 3,
description: 'Local AI chat that runs entirely on your hardware - no internet required',
icon: 'IconWand',
container_image: 'ollama/ollama:0.18.1',
container_image: 'ollama/ollama:0.15.2',
source_repo: 'https://github.com/ollama/ollama',
container_command: 'serve',
container_config: JSON.stringify({
@ -94,7 +94,7 @@ export default class ServiceSeeder extends BaseSeeder {
display_order: 11,
description: 'Swiss Army knife for data encoding, encryption, and analysis',
icon: 'IconChefHat',
container_image: 'ghcr.io/gchq/cyberchef:10.22.1',
container_image: 'ghcr.io/gchq/cyberchef:10.19.4',
source_repo: 'https://github.com/gchq/CyberChef',
container_command: null,
container_config: JSON.stringify({

View File

@ -1,26 +1,6 @@
# Release Notes
## Version 1.30.3 - March 25, 2026
### Features
### Bug Fixes
- **Benchmark**: Fixed an issue where CPU and Disk Write scores could be displayed as 0 if the measured values was less than half of the reference mark. Thanks @bortlesboat for the fix!
- **Content Manager**: Fixed a missing API client method that was causing ZIM file deletions to fail. Thanks @LuisMIguelFurlanettoSousa for the fix!
- **Install**: Fixed an issue where the install script could incorrectly report the Docker NVIDIA runtime as missing. Thanks @brenex for the fix!
- **Support the Project**: Fixed a broken link to Rogue Support. Thanks @chriscrosstalk for the fix!
### Improvements
- **AI Assistant**: Improved error reporting and handling for model downloads. Thanks @chriscrosstalk for the contribution!
- **AI Assistant**: Bumped the default version of Ollama installed to v0.18.1 to take advantage of the latest performance improvements and bug fixes.
- **Apps**: Improved error reporting and handling for service installation failures. Thanks @trek-e for the contribution!
- **Collections**: Updated various curated collection links to their latest versions. Thanks @builder555 for the contribution!
- **Cyberchef**: Bumped the default version of CyberChef installed to v10.22.1 to take advantage of the latest features and bug fixes.
- **Docs**: Added a link to the step-by-step installation guide and video tutorial. Thanks @chriscrosstalk for the contribution!
- **Install**: Increased the retries limit for the MySQL service in Docker Compose to improve stability during installation on systems with slower performance. Thanks @dx4956 for the contribution!
- **Install**: Fixed an issue where stale data could cause credentials mismatch in MySQL on reinstall. Thanks @chriscrosstalk for the fix!
## Version 1.30.0 - March 20, 2026
## Unreleased
### Features
- **Night Ops**: Added our most requested feature — a dark mode theme for the Command Center interface! Activate it from the footer and enjoy the sleek new look during your late-night missions. Thanks @chriscrosstalk for the contribution!
@ -30,21 +10,13 @@
### Bug Fixes
- **Settings**: Storage usage display now prefers real block devices over tempfs. Thanks @Bortlesboat for the fix!
- **Settings**: Fixed an issue where device matching and mount entry deduplication logic could cause incorrect storage usage reporting and missing devices in storage displays.
- **Maps**: The Maps page now respects the request protocol (http vs https) to ensure map tiles load correctly. Thanks @davidgross for the bug report!
- **Knowledge Base**: Fixed an issue where file embedding jobs could cause a retry storm if the Ollama service was unavailable. Thanks @skyam25 for the bug report!
- **Curated Collections**: Fixed some broken links in the curated collections definitions (maps and ZIM files) that were causing some resources to fail to download.
- **Easy Setup**: Fixed an issue where the "Start Here" badge would persist even after visiting the Easy Setup Wizard for the first time. Thanks @chriscrosstalk for the fix!
- **UI**: Fixed an issue where the loading spinner could look strange in certain use cases.
- **System Updates**: Fixed an issue where the update banner would persist even after the system was updated successfully. Thanks @chriscrosstalk for the fix!
- **Performance**: Various small memory leak fixes and performance improvements across the UI to ensure a smoother experience.
### Improvements
- **Ollama**: Improved GPU detection logic to ensure the latest GPU config is always passed to the Ollama container on update
- **Ollama**: The detected GPU type is now persisted in the database for more reliable configuration and troubleshooting across updates and restarts. Thanks @chriscrosstalk for the contribution!
- **Downloads**: Users can now dismiss failed download notifications to reduce clutter in the UI. Thanks @chriscrosstalk for the contribution!
- **Logging**: Changed the default log level to "info" to reduce noise and focus on important messages. Thanks @traxeon for the suggestion!
- **Logging**: Nomad's internal logger now creates it's own log directory on startup if it doesn't already exist to prevent errors on fresh installs where the logs directory hasn't been created yet.
- **Dozzle**: Dozzle shell access and container actions are now disabled by default. Thanks @traxeon for the recommendation!
- **MySQL & Redis**: Removed port exposure to host by default for improved security. Ports can still be exposed manually if needed. Thanks @traxeon for the recommendation!
- **Dependencies**: Various dependency updates to close security vulnerabilities and improve stability

View File

@ -0,0 +1,281 @@
# Project NOMAD Security Audit Report
**Date:** 2026-03-08
**Version audited:** v1.28.0 (main branch)
**Auditor:** Claude Code (automated + manual review)
**Target:** Pre-launch security review
---
## Executive Summary
Project NOMAD's codebase is **reasonably clean for a LAN appliance**, with no critical authentication bypasses or remote code execution vulnerabilities. However, there are **4 findings that should be fixed before public launch** — all are straightforward path traversal and SSRF issues with known fix patterns already used elsewhere in the codebase.
| Severity | Count | Summary |
|----------|-------|---------|
| **HIGH** | 4 | Path traversal (3), SSRF (1) |
| **MEDIUM** | 5 | Dozzle shell, unvalidated settings read, content update URL injection, verbose errors, no rate limiting |
| **LOW** | 5 | CSRF disabled, CORS wildcard, debug logging, npm dep CVEs, hardcoded HMAC |
| **INFO** | 2 | No auth by design, Docker socket exposure by design |
---
## Scans Performed
| Scan | Tool | Result |
|------|------|--------|
| Dependency audit | `npm audit` | 2 CVEs (1 high, 1 moderate) |
| Secret scan | Manual grep (passwords, keys, tokens, certs) | Clean — all secrets from env vars |
| SAST | Semgrep (security-audit, OWASP, nodejs rulesets) | 0 findings (AdonisJS not in rulesets) |
| Docker config review | Manual review of compose, Dockerfiles, scripts | 2 actionable findings |
| Code review | Manual review of services, controllers, validators | 4 path traversal + 1 SSRF |
| API endpoint audit | Manual review of all 60+ routes | Attack surface documented |
| DAST (OWASP ZAP) | Skipped — Docker Desktop not running | Recommended as follow-up |
---
## FIX BEFORE LAUNCH
### 1. Path Traversal — ZIM File Delete (HIGH)
**File:** `admin/app/services/zim_service.ts:329-342`
**Endpoint:** `DELETE /api/zim/:filename`
The `filename` parameter flows into `path.join()` with no directory containment check. An attacker can delete `.zim` files outside the storage directory:
```
DELETE /api/zim/..%2F..%2Fsome-file.zim
```
**Fix:** Resolve the full path and verify it starts with the expected storage directory:
```typescript
async delete(file: string): Promise<void> {
let fileName = file
if (!fileName.endsWith('.zim')) {
fileName += '.zim'
}
const basePath = join(process.cwd(), ZIM_STORAGE_PATH)
const fullPath = resolve(basePath, fileName)
// Prevent path traversal
if (!fullPath.startsWith(basePath)) {
throw new Error('Invalid filename')
}
// ... rest of delete logic
}
```
This pattern is already used correctly in `rag_service.ts:deleteFileBySource()`.
---
### 2. Path Traversal — Map File Delete (HIGH)
**File:** `admin/app/services/map_service.ts` (delete method)
**Endpoint:** `DELETE /api/maps/:filename`
Identical pattern to the ZIM delete. Same fix — resolve path, verify `startsWith(basePath)`.
---
### 3. Path Traversal — Documentation Read (HIGH)
**File:** `admin/app/services/docs_service.ts:61-83`
**Endpoint:** `GET /docs/:slug`
The `slug` parameter flows into `path.join(this.docsPath, filename)` with no containment check. An attacker can read arbitrary `.md` files on the filesystem:
```
GET /docs/..%2F..%2F..%2Fetc%2Fpasswd
```
Limited by the mandatory `.md` extension, but could still read sensitive markdown files outside the docs directory (like CLAUDE.md, README.md, etc.).
**Fix:**
```typescript
const basePath = this.docsPath
const fullPath = path.resolve(basePath, filename)
if (!fullPath.startsWith(path.resolve(basePath))) {
throw new Error('Invalid document slug')
}
```
---
### 4. SSRF — Download Endpoints (HIGH)
**File:** `admin/app/validators/common.ts`
**Endpoints:** `POST /api/zim/download-remote`, `POST /api/maps/download-remote`, `POST /api/maps/download-base-assets`, `POST /api/maps/download-remote-preflight`
The download endpoints accept user-supplied URLs and the server fetches from them. Without validation, an attacker on the LAN (or via CSRF since `shield.ts` disables CSRF protection) could make NOMAD fetch from co-located services:
- `http://localhost:3306` (MySQL)
- `http://localhost:6379` (Redis)
- `http://169.254.169.254/` (cloud metadata — if NOMAD is ever cloud-hosted)
**Fix:** Added `assertNotPrivateUrl()` that blocks loopback and link-local addresses before any download is initiated. Called in all download controllers.
**Scope note:** RFC1918 private addresses (10.x, 172.16-31.x, 192.168.x) are intentionally **allowed** because NOMAD is a LAN appliance and users may host content mirrors on their local network. The `require_tld: false` VineJS option is preserved so URLs like `http://my-nas:8080/file.zim` remain valid.
```typescript
const blockedPatterns = [
/^localhost$/,
/^127\.\d+\.\d+\.\d+$/,
/^0\.0\.0\.0$/,
/^169\.254\.\d+\.\d+$/, // Link-local / cloud metadata
/^\[::1\]$/,
/^\[?fe80:/i, // IPv6 link-local
]
```
---
## FIX AFTER LAUNCH (Medium Priority)
### 5. Dozzle Web Shell Access (MEDIUM)
**File:** `install/management_compose.yaml:56`
```yaml
- DOZZLE_ENABLE_SHELL=true
```
Dozzle on port 9999 is bound to all interfaces with shell access enabled. Anyone on the LAN can open a web shell into containers, including `nomad_admin` which has the Docker socket mounted. This creates a path from "LAN access" → "container shell" → "Docker socket" → "host root."
**Fix:** Set `DOZZLE_ENABLE_SHELL=false`. Log viewing and container restart functionality are preserved.
---
### 6. Unvalidated Settings Key Read (MEDIUM)
**File:** `admin/app/controllers/settings_controller.ts`
**Endpoint:** `GET /api/system/settings?key=...`
The `updateSetting` endpoint validates the key against an enum, but `getSetting` accepts any arbitrary key string. Currently harmless since the KV store only contains settings data, but could leak sensitive info if new keys are added.
**Fix:** Apply the same enum validation to the read endpoint.
---
### 7. Content Update URL Injection (MEDIUM)
**File:** `admin/app/validators/common.ts:72-88`
**Endpoint:** `POST /api/content-updates/apply`
The `download_url` comes directly from the client request body. An attacker can supply any URL and NOMAD will download from it. The URL should be looked up server-side from the content manifest instead.
**Fix:** Validate `download_url` against the cached manifest, or apply the same loopback/link-local protections as finding #4 (already applied in this PR).
---
### 8. Verbose Error Messages (MEDIUM)
**Files:** `rag_controller.ts`, `docker_service.ts`, `system_update_service.ts`
Several controllers return raw `error.message` in API responses, potentially leaking internal paths, stack details, or Docker error messages to the client.
**Fix:** Return generic error messages in production. Log the details server-side.
---
### 9. No Rate Limiting (MEDIUM)
Zero rate limiting across all 60+ endpoints. While acceptable for a LAN appliance, some endpoints are particularly abusable:
- `POST /api/benchmark/run` — spins up Docker containers for CPU/memory/disk stress tests
- `POST /api/rag/upload` — file uploads (20MB limit per bodyparser config)
- `POST /api/system/services/affect` — can stop/start any service repeatedly
**Fix:** Consider basic rate limiting on the benchmark and service control endpoints (e.g., 1 benchmark per minute, service actions throttled to prevent rapid cycling).
---
## LOW PRIORITY / ACCEPTED RISK
### 10. CSRF Protection Disabled (LOW)
**File:** `admin/config/shield.ts`
CSRF is disabled, meaning any website a LAN user visits could fire requests at NOMAD's API. This amplifies findings 1-4 — path traversal and SSRF could be triggered by a malicious webpage, not just direct LAN access.
**Assessment:** Acceptable for a LAN appliance with no auth system. Enabling CSRF would require significant auth/session infrastructure changes.
### 11. CORS Wildcard with Credentials (LOW)
**File:** `admin/config/cors.ts`
`origin: ['*']` with `credentials: true`. Standard for LAN appliances.
### 12. npm Dependency CVEs (LOW)
```
tar <=7.5.9 HIGH Hardlink Path Traversal via Drive-Relative Linkpath
ajv <6.14.0 MODERATE ReDoS when using $data option
```
Both fixable via `npm audit fix`. Low practical risk since these are build/dev dependencies not directly exposed to user input.
**Fix:** Run `npm audit fix` and commit the updated lockfile.
### 13. Hardcoded HMAC Secret (LOW)
**File:** `admin/app/services/benchmark_service.ts:35`
The benchmark HMAC secret `'nomad-benchmark-v1-2026'` is hardcoded in open-source code. Anyone can forge leaderboard submissions.
**Assessment:** Accepted risk. The leaderboard has compensating controls (rate limiting, plausibility validation, hardware fingerprint dedup). The secret stops casual abuse, not determined attackers.
### 14. Production Debug Logging (LOW)
**File:** `install/management_compose.yaml:22`
```yaml
LOG_LEVEL=debug
```
Debug logging in production can expose internal state in log files.
**Fix:** Change to `LOG_LEVEL=info` for production compose template.
---
## INFORMATIONAL (By Design)
### No Authentication
All 60+ API endpoints are unauthenticated. This is by design — NOMAD is a LAN appliance and the network boundary is the access control. Issue #73 tracks the edge case of public IP interfaces.
### Docker Socket Exposure
The `nomad_admin` container mounts `/var/run/docker.sock`. This is necessary for NOMAD's core functionality (managing Docker containers). The socket is not exposed to the network — only the admin container can use it.
---
## Recommendations Summary
| Priority | Action | Effort |
|----------|--------|--------|
| **Before launch** | Fix 3 path traversals (ZIM delete, Map delete, Docs read) | ~30 min |
| **Before launch** | Add SSRF protection to download URL validators | ~1 hour |
| **Soon after** | Disable Dozzle shell access | 1 line change |
| **Soon after** | Validate settings key on read endpoint | ~15 min |
| **Soon after** | Sanitize error messages in responses | ~30 min |
| **Nice to have** | Run `npm audit fix` | 5 min |
| **Nice to have** | Change production log level to info | 1 line change |
| **Follow-up** | OWASP ZAP dynamic scan against NOMAD3 | ~1 hour |
---
## What Went Right
- **No hardcoded secrets** — all credentials properly use environment variables
- **No command injection** — Docker operations use the Docker API (dockerode), not shell commands
- **No SQL injection** — all database queries use AdonisJS Lucid ORM with parameterized queries
- **No eval/Function** — no dynamic code execution anywhere
- **RAG service already has the correct fix pattern**`deleteFileBySource()` uses `resolve()` + `startsWith()` for path containment
- **Install script generates strong random passwords** — uses `/dev/urandom` for APP_KEY and DB passwords
- **No privileged containers** — GPU passthrough uses DeviceRequests, not --privileged
- **Health checks don't leak data** — internal-only calls

View File

@ -40,7 +40,7 @@ createInertiaApp({
createRoot(el).render(
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<TransmitProvider baseUrl={window.location.origin} enableLogging={environment === 'development'}>
<TransmitProvider baseUrl={window.location.origin} enableLogging={true}>
<NotificationsProvider>
<ModalsProvider>
<App {...props} />

View File

@ -2,8 +2,7 @@ import useDownloads, { useDownloadsProps } from '~/hooks/useDownloads'
import HorizontalBarChart from './HorizontalBarChart'
import { extractFileName } from '~/lib/util'
import StyledSectionHeader from './StyledSectionHeader'
import { IconAlertTriangle, IconX } from '@tabler/icons-react'
import api from '~/lib/api'
import { IconAlertTriangle } from '@tabler/icons-react'
interface ActiveDownloadProps {
filetype?: useDownloadsProps['filetype']
@ -11,12 +10,7 @@ interface ActiveDownloadProps {
}
const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps) => {
const { data: downloads, invalidate } = useDownloads({ filetype })
const handleDismiss = async (jobId: string) => {
await api.removeDownloadJob(jobId)
invalidate()
}
const { data: downloads } = useDownloads({ filetype })
return (
<>
@ -43,13 +37,6 @@ const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps)
Download failed{download.failedReason ? `: ${download.failedReason}` : ''}
</p>
</div>
<button
onClick={() => handleDismiss(download.jobId)}
className="flex-shrink-0 p-1 rounded hover:bg-red-100 transition-colors"
title="Dismiss failed download"
>
<IconX className="w-4 h-4 text-red-400 hover:text-red-600" />
</button>
</div>
) : (
<HorizontalBarChart

View File

@ -1,7 +1,6 @@
import useOllamaModelDownloads from '~/hooks/useOllamaModelDownloads'
import HorizontalBarChart from './HorizontalBarChart'
import StyledSectionHeader from './StyledSectionHeader'
import { IconAlertTriangle } from '@tabler/icons-react'
interface ActiveModelDownloadsProps {
withHeader?: boolean
@ -18,31 +17,19 @@ const ActiveModelDownloads = ({ withHeader = false }: ActiveModelDownloadsProps)
downloads.map((download) => (
<div
key={download.model}
className={`bg-desert-white rounded-lg p-4 border shadow-sm hover:shadow-lg transition-shadow ${
download.error ? 'border-red-400' : 'border-desert-stone-light'
}`}
className="bg-desert-white rounded-lg p-4 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow"
>
{download.error ? (
<div className="flex items-start gap-3">
<IconAlertTriangle className="text-red-500 flex-shrink-0 mt-0.5" size={20} />
<div>
<p className="font-medium text-text-primary">{download.model}</p>
<p className="text-sm text-red-600 mt-1">{download.error}</p>
</div>
</div>
) : (
<HorizontalBarChart
items={[
{
label: download.model,
value: download.percent,
total: '100%',
used: `${download.percent.toFixed(1)}%`,
type: 'ollama-model',
},
]}
/>
)}
<HorizontalBarChart
items={[
{
label: download.model,
value: download.percent,
total: '100%',
used: `${download.percent.toFixed(1)}%`,
type: 'ollama-model',
},
]}
/>
</div>
))
) : (

View File

@ -15,9 +15,9 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
}) => {
if (!fullscreen) {
return (
<div className="flex flex-col items-center justify-center">
<div className={`flex flex-col items-center justify-center ${className}`}>
<div
className={`w-8 h-8 border-[3px] ${light ? 'border-white' : 'border-text-muted'} border-t-transparent rounded-full animate-spin ${className || ''}`}
className={`w-8 h-8 border-[3px] ${light ? 'border-white' : 'border-text-muted'} border-t-transparent rounded-full animate-spin`}
></div>
{!iconOnly && (
<div className={light ? 'text-white mt-2' : 'text-text-primary mt-2'}>

View File

@ -78,11 +78,11 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
</li>
</ul>
</nav>
<div className="mb-4 flex flex-col items-center gap-1 text-sm text-text-secondary text-center">
<div className="mb-4 flex flex-col items-center gap-1 text-sm text-text-secondary">
<p>Project N.O.M.A.D. Command Center v{appVersion}</p>
<button
onClick={() => setDebugModalOpen(true)}
className="text-gray-500 hover:text-desert-green inline-flex items-center gap-1 cursor-pointer"
className="mt-1 text-gray-500 hover:text-desert-green inline-flex items-center gap-1 cursor-pointer"
>
<IconBug className="size-3.5" />
Debug Info

View File

@ -13,7 +13,7 @@ export default function ThemeToggle({ compact = false }: ThemeToggleProps) {
<button
onClick={toggleTheme}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm transition-colors
text-desert-stone hover:text-desert-green-darker cursor-pointer"
text-desert-stone hover:text-desert-green-darker"
aria-label={isDark ? 'Switch to Day Ops' : 'Switch to Night Ops'}
title={isDark ? 'Switch to Day Ops' : 'Switch to Night Ops'}
>

View File

@ -6,7 +6,6 @@ import { resolveTierResources } from '~/lib/collections'
import { formatBytes } from '~/lib/util'
import classNames from 'classnames'
import DynamicIcon, { DynamicIconName } from './DynamicIcon'
import StyledButton from './StyledButton'
interface TierSelectionModalProps {
isOpen: boolean
@ -214,14 +213,18 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
{/* Footer */}
<div className="bg-surface-secondary px-6 py-4 flex justify-end gap-3">
<StyledButton
variant='primary'
size='lg'
<button
onClick={handleSubmit}
disabled={!localSelectedSlug}
className={classNames(
'px-4 py-2 rounded-md font-medium transition-colors',
localSelectedSlug
? 'bg-desert-green text-white hover:bg-desert-green/90'
: 'bg-border-default text-text-muted cursor-not-allowed'
)}
>
Submit
</StyledButton>
</button>
</div>
</Dialog.Panel>
</Transition.Child>

View File

@ -49,7 +49,7 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
{/* Downloading status message */}
{isDownloading && (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-center gap-2">
<LoadingSpinner fullscreen={false} iconOnly className="size-4" />
<LoadingSpinner fullscreen={false} iconOnly className="size-5" />
<span className="text-sm text-blue-700">
Downloading Wikipedia... This may take a while for larger packages.
</span>

View File

@ -1,82 +0,0 @@
import { NomadDiskInfo } from '../../types/system'
import { Systeminformation } from 'systeminformation'
import { formatBytes } from '~/lib/util'
type DiskDisplayItem = {
label: string
value: number
total: string
used: string
subtext: string
totalBytes: number
usedBytes: number
}
/** Get all valid disks formatted for display (settings/system page) */
export function getAllDiskDisplayItems(
disks: NomadDiskInfo[] | undefined,
fsSize: Systeminformation.FsSizeData[] | undefined
): DiskDisplayItem[] {
const validDisks = disks?.filter((d) => d.totalSize > 0) || []
if (validDisks.length > 0) {
return validDisks.map((disk) => ({
label: disk.name || 'Unknown',
value: disk.percentUsed || 0,
total: formatBytes(disk.totalSize),
used: formatBytes(disk.totalUsed),
subtext: `${formatBytes(disk.totalUsed || 0)} / ${formatBytes(disk.totalSize || 0)}`,
totalBytes: disk.totalSize,
usedBytes: disk.totalUsed,
}))
}
if (fsSize && fsSize.length > 0) {
const seen = new Set<number>()
const uniqueFs = fsSize.filter((fs) => {
if (fs.size <= 0 || seen.has(fs.size)) return false
seen.add(fs.size)
return true
})
const realDevices = uniqueFs.filter((fs) => fs.fs.startsWith('/dev/'))
const displayFs = realDevices.length > 0 ? realDevices : uniqueFs
return displayFs.map((fs) => ({
label: fs.fs || 'Unknown',
value: fs.use || 0,
total: formatBytes(fs.size),
used: formatBytes(fs.used),
subtext: `${formatBytes(fs.used)} / ${formatBytes(fs.size)}`,
totalBytes: fs.size,
usedBytes: fs.used,
}))
}
return []
}
/** Get primary disk info for storage projection (easy-setup page) */
export function getPrimaryDiskInfo(
disks: NomadDiskInfo[] | undefined,
fsSize: Systeminformation.FsSizeData[] | undefined
): { totalSize: number; totalUsed: number } | null {
const validDisks = disks?.filter((d) => d.totalSize > 0) || []
if (validDisks.length > 0) {
const diskWithRoot = validDisks.find((d) =>
d.filesystems?.some((fs) => fs.mount === '/' || fs.mount === '/storage')
)
const primary =
diskWithRoot || validDisks.reduce((a, b) => (b.totalSize > a.totalSize ? b : a))
return { totalSize: primary.totalSize, totalUsed: primary.totalUsed }
}
if (fsSize && fsSize.length > 0) {
const realDevices = fsSize.filter((fs) => fs.fs.startsWith('/dev/'))
const primary =
realDevices.length > 0
? realDevices.reduce((a, b) => (b.size > a.size ? b : a))
: fsSize[0]
return { totalSize: primary.size, totalUsed: primary.used }
}
return null
}

View File

@ -17,11 +17,7 @@ const useDownloads = (props: useDownloadsProps) => {
const queryData = useQuery({
queryKey: queryKey,
queryFn: () => api.listDownloadJobs(props.filetype),
refetchInterval: (query) => {
const data = query.state.data
// Only poll when there are active downloads; otherwise use a slower interval
return data && data.length > 0 ? 2000 : 30000
},
refetchInterval: 2000, // Refetch every 2 seconds to get updated progress
enabled: props.enabled ?? true,
})

View File

@ -7,11 +7,7 @@ const useEmbedJobs = (props: { enabled?: boolean } = {}) => {
const queryData = useQuery({
queryKey: ['embed-jobs'],
queryFn: () => api.getActiveEmbedJobs().then((data) => data ?? []),
refetchInterval: (query) => {
const data = query.state.data
// Only poll when there are active jobs; otherwise use a slower interval
return data && data.length > 0 ? 2000 : 30000
},
refetchInterval: 2000,
enabled: props.enabled ?? true,
})

View File

@ -1,47 +1,31 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import { useTransmit } from 'react-adonis-transmit'
export type OllamaModelDownload = {
model: string
percent: number
timestamp: string
error?: string
}
export default function useOllamaModelDownloads() {
const { subscribe } = useTransmit()
const [downloads, setDownloads] = useState<Map<string, OllamaModelDownload>>(new Map())
const timeoutsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set())
useEffect(() => {
const unsubscribe = subscribe('ollama-model-download', (data: OllamaModelDownload) => {
setDownloads((prev) => {
const updated = new Map(prev)
if (data.percent === -1) {
// Download failed — show error state, auto-remove after 15 seconds
updated.set(data.model, data)
const errorTimeout = setTimeout(() => {
timeoutsRef.current.delete(errorTimeout)
setDownloads((current) => {
const next = new Map(current)
next.delete(data.model)
return next
})
}, 15000)
timeoutsRef.current.add(errorTimeout)
} else if (data.percent >= 100) {
if (data.percent >= 100) {
// If download is complete, keep it for a short time before removing to allow UI to show 100% progress
updated.set(data.model, data)
const timeout = setTimeout(() => {
timeoutsRef.current.delete(timeout)
setTimeout(() => {
setDownloads((current) => {
const next = new Map(current)
next.delete(data.model)
return next
})
}, 2000)
timeoutsRef.current.add(timeout)
} else {
updated.set(data.model, data)
}
@ -52,10 +36,7 @@ export default function useOllamaModelDownloads() {
return () => {
unsubscribe()
timeoutsRef.current.forEach(clearTimeout)
timeoutsRef.current.clear()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subscribe])
const downloadsArray = Array.from(downloads.values())

View File

@ -1,4 +1,4 @@
import axios, { AxiosError, AxiosInstance } from 'axios'
import axios, { AxiosInstance } from 'axios'
import { ListRemoteZimFilesResponse, ListZimFilesResponse } from '../../types/zim'
import { ServiceSlim } from '../../types/services'
import { FileEntry } from '../../types/files'
@ -25,19 +25,13 @@ class API {
}
async affectService(service_name: string, action: 'start' | 'stop' | 'restart') {
try {
return catchInternal(async () => {
const response = await this.client.post<{ success: boolean; message: string }>(
'/system/services/affect',
{ service_name, action }
)
return response.data
} catch (error) {
if (error instanceof AxiosError && error.response?.data?.message) {
return { success: false, message: error.response.data.message }
}
console.error('Error affecting service:', error)
return undefined
}
})()
}
async checkLatestVersion(force: boolean = false) {
@ -198,19 +192,13 @@ class API {
}
async forceReinstallService(service_name: string) {
try {
return catchInternal(async () => {
const response = await this.client.post<{ success: boolean; message: string }>(
`/system/services/force-reinstall`,
{ service_name }
)
return response.data
} catch (error) {
if (error instanceof AxiosError && error.response?.data?.message) {
return { success: false, message: error.response.data.message }
}
console.error('Error force reinstalling service:', error)
return undefined
}
})()
}
async getChatSuggestions(signal?: AbortSignal) {
@ -471,19 +459,13 @@ class API {
}
async installService(service_name: string) {
try {
return catchInternal(async () => {
const response = await this.client.post<{ success: boolean; message: string }>(
'/system/services/install',
{ service_name }
)
return response.data
} catch (error) {
if (error instanceof AxiosError && error.response?.data?.message) {
return { success: false, message: error.response.data.message }
}
console.error('Error installing service:', error)
return undefined
}
})()
}
async listCuratedMapCollections() {
@ -536,13 +518,6 @@ class API {
})()
}
async deleteZimFile(filename: string) {
return catchInternal(async () => {
const response = await this.client.delete<{ message: string }>(`/zim/${filename}`)
return response.data
})()
}
async listZimFiles() {
return catchInternal(async () => {
return await this.client.get<ListZimFilesResponse>('/zim/list')
@ -557,12 +532,6 @@ class API {
})()
}
async removeDownloadJob(jobId: string): Promise<void> {
return catchInternal(async () => {
await this.client.delete(`/downloads/jobs/${jobId}`)
})()
}
async runBenchmark(type: BenchmarkType, sync: boolean = false) {
return catchInternal(async () => {
const response = await this.client.post<RunBenchmarkResponse>(

View File

@ -16,7 +16,6 @@ import StorageProjectionBar from '~/components/StorageProjectionBar'
import { useNotifications } from '~/context/NotificationContext'
import useInternetStatus from '~/hooks/useInternetStatus'
import { useSystemInfo } from '~/hooks/useSystemInfo'
import { getPrimaryDiskInfo } from '~/hooks/useDiskDisplayData'
import classNames from 'classnames'
import type { CategoryWithStatus, SpecTier, SpecResource } from '../../../types/collections'
import { resolveTierResources } from '~/lib/collections'
@ -297,7 +296,46 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
])
// Get primary disk/filesystem info for storage projection
const storageInfo = getPrimaryDiskInfo(systemInfo?.disk, systemInfo?.fsSize)
// Try disk array first (Linux/production), fall back to fsSize (Windows/dev)
// Filter out invalid disks (totalSize === 0) and prefer disk with root mount or largest valid disk
const getPrimaryDisk = () => {
if (!systemInfo?.disk || systemInfo.disk.length === 0) return null
// Filter to only valid disks with actual storage
const validDisks = systemInfo.disk.filter((d) => d.totalSize > 0)
if (validDisks.length === 0) return null
// Prefer disk containing root mount (/) or /storage mount
const diskWithRoot = validDisks.find((d) =>
d.filesystems?.some((fs) => fs.mount === '/' || fs.mount === '/storage')
)
if (diskWithRoot) return diskWithRoot
// Fall back to largest valid disk
return validDisks.reduce((largest, current) =>
current.totalSize > largest.totalSize ? current : largest
)
}
const primaryDisk = getPrimaryDisk()
// When falling back to fsSize (systeminformation), prefer real block devices
// over virtual filesystems like tmpfs which report misleading capacity.
const getPrimaryFs = () => {
if (!systemInfo?.fsSize || systemInfo.fsSize.length === 0) return null
const realDevices = systemInfo.fsSize.filter((fs) => fs.fs.startsWith('/dev/'))
if (realDevices.length > 0) {
return realDevices.reduce((largest, current) =>
current.size > largest.size ? current : largest
)
}
return systemInfo.fsSize[0]
}
const primaryFs = getPrimaryFs()
const storageInfo = primaryDisk
? { totalSize: primaryDisk.totalSize, totalUsed: primaryDisk.totalUsed }
: primaryFs
? { totalSize: primaryFs.size, totalUsed: primaryFs.used }
: null
const canProceedToNextStep = () => {
if (!isOnline) return false // Must be online to proceed

View File

@ -97,7 +97,7 @@ export default function Home(props: {
const { data: easySetupVisited } = useSystemSetting({
key: 'ui.hasVisitedEasySetup'
})
const shouldHighlightEasySetup = easySetupVisited?.value ? String(easySetupVisited.value) !== 'true' : false
const shouldHighlightEasySetup = easySetupVisited?.value ? easySetupVisited?.value !== 'true' : false
// Add installed services (non-dependency services only)
props.system.services

View File

@ -1,5 +1,5 @@
import { Head, Link, usePage } from '@inertiajs/react'
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect } from 'react'
import SettingsLayout from '~/layouts/SettingsLayout'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import CircularGauge from '~/components/systeminfo/CircularGauge'
@ -40,7 +40,6 @@ export default function BenchmarkPage(props: {
const aiInstalled = useServiceInstalledStatus(SERVICE_NAMES.OLLAMA)
const [progress, setProgress] = useState<BenchmarkProgressWithID | null>(null)
const [isRunning, setIsRunning] = useState(props.benchmark.status !== 'idle')
const refetchLatestRef = useRef<(() => void) | null>(null)
const [showDetails, setShowDetails] = useState(false)
const [showHistory, setShowHistory] = useState(false)
const [showAIRequiredAlert, setShowAIRequiredAlert] = useState(false)
@ -61,7 +60,6 @@ export default function BenchmarkPage(props: {
},
initialData: props.benchmark.latestResult,
})
refetchLatestRef.current = refetchLatest
// Fetch all benchmark results for history
const { data: benchmarkHistory } = useQuery({
@ -308,15 +306,14 @@ export default function BenchmarkPage(props: {
setProgress(data)
if (data.status === 'completed' || data.status === 'error') {
setIsRunning(false)
refetchLatestRef.current?.()
refetchLatest()
}
})
return () => {
unsubscribe()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subscribe])
}, [subscribe, refetchLatest])
const formatBytes = (bytes: number) => {
const gb = bytes / (1024 * 1024 * 1024)

View File

@ -36,7 +36,7 @@ export default function SupportPage() {
<section className="mb-12">
<h2 className="text-2xl font-semibold mb-3">Need Help With Your Home Network?</h2>
<a
href="https://rogue.support"
href="https://roguesupport.com"
target="_blank"
rel="noopener noreferrer"
className="block mb-4 rounded-lg overflow-hidden hover:opacity-90 transition-opacity"
@ -52,12 +52,12 @@ export default function SupportPage() {
Think of it as Uber for computer networking expert help when you need it.
</p>
<a
href="https://rogue.support"
href="https://roguesupport.com"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-blue-600 hover:underline font-medium"
>
Visit Rogue.Support
Visit RogueSupport.com
<IconExternalLink size={16} />
</a>
</section>

View File

@ -3,7 +3,6 @@ import { Head } from '@inertiajs/react'
import SettingsLayout from '~/layouts/SettingsLayout'
import { SystemInformationResponse } from '../../../types/system'
import { formatBytes } from '~/lib/util'
import { getAllDiskDisplayItems } from '~/hooks/useDiskDisplayData'
import CircularGauge from '~/components/systeminfo/CircularGauge'
import HorizontalBarChart from '~/components/HorizontalBarChart'
import InfoCard from '~/components/systeminfo/InfoCard'
@ -106,7 +105,42 @@ export default function SettingsPage(props: {
: `${uptimeMinutes}m`
// Build storage display items - fall back to fsSize when disk array is empty
const storageItems = getAllDiskDisplayItems(info?.disk, info?.fsSize)
// (Same approach as Easy Setup wizard fix from PR #90)
const validDisks = info?.disk?.filter((d) => d.totalSize > 0) || []
let storageItems: {
label: string
value: number
total: string
used: string
subtext: string
}[] = []
if (validDisks.length > 0) {
storageItems = validDisks.map((disk) => ({
label: disk.name || 'Unknown',
value: disk.percentUsed || 0,
total: disk.totalSize ? formatBytes(disk.totalSize) : 'N/A',
used: disk.totalUsed ? formatBytes(disk.totalUsed) : 'N/A',
subtext: `${formatBytes(disk.totalUsed || 0)} / ${formatBytes(disk.totalSize || 0)}`,
}))
} else if (info?.fsSize && info.fsSize.length > 0) {
// Deduplicate by size (same physical disk mounted in multiple places shows identical sizes)
const seen = new Set<number>()
const uniqueFs = info.fsSize.filter((fs) => {
if (fs.size <= 0 || seen.has(fs.size)) return false
seen.add(fs.size)
return true
})
// Prefer real block devices (/dev/), exclude virtual filesystems (efivarfs, tmpfs, etc.)
const realDevices = uniqueFs.filter((fs) => fs.fs.startsWith('/dev/'))
const displayFs = realDevices.length > 0 ? realDevices : uniqueFs
storageItems = displayFs.map((fs) => ({
label: fs.fs || 'Unknown',
value: fs.use || 0,
total: formatBytes(fs.size),
used: formatBytes(fs.used),
subtext: `${formatBytes(fs.used)} / ${formatBytes(fs.size)}`,
}))
}
return (
<SettingsLayout>

View File

@ -258,13 +258,7 @@ export default function SystemUpdatePage(props: { system: Props }) {
// Check if update is complete or errored
if (response.stage === 'complete') {
// Re-check version so the KV store clears the stale "update available" flag
// before we reload, otherwise the banner shows "current → current"
try {
await api.checkLatestVersion(true)
} catch {
// Non-critical - page reload will still work
}
// Give a moment for the new container to fully start
setTimeout(() => {
window.location.reload()
}, 2000)

View File

@ -92,7 +92,6 @@ router
.group(() => {
router.get('/jobs', [DownloadsController, 'index'])
router.get('/jobs/:filetype', [DownloadsController, 'filetype'])
router.delete('/jobs/:jobId', [DownloadsController, 'removeJob'])
})
.prefix('/api/downloads')

View File

@ -9,7 +9,6 @@ export const KV_STORE_SCHEMA = {
'ui.hasVisitedEasySetup': 'boolean',
'ui.theme': 'string',
'ai.assistantCustomName': 'string',
'gpu.type': 'string',
} as const
type KVTagToType<T extends string> = T extends 'boolean' ? boolean : string

View File

@ -1,5 +1,5 @@
{
"spec_version": "2026-03-15",
"spec_version": "2026-02-11",
"categories": [
{
"name": "Medicine",
@ -113,10 +113,10 @@
"resources": [
{
"id": "canadian_prepper_winterprepping_en",
"version": "2026-02",
"version": "2025-11",
"title": "Canadian Prepper: Winter Prepping",
"description": "Video guides for winter survival and cold weather emergencies",
"url": "https://download.kiwix.org/zim/videos/canadian_prepper_winterprepping_en_2026-02.zim",
"url": "https://download.kiwix.org/zim/videos/canadian_prepper_winterprepping_en_2025-11.zim",
"size_mb": 1340
},
{
@ -137,18 +137,18 @@
"resources": [
{
"id": "canadian_prepper_bugoutconcepts_en",
"version": "2026-02",
"version": "2025-11",
"title": "Canadian Prepper: Bug Out Concepts",
"description": "Strategies and planning for emergency evacuation",
"url": "https://download.kiwix.org/zim/videos/canadian_prepper_bugoutconcepts_en_2026-02.zim",
"url": "https://download.kiwix.org/zim/videos/canadian_prepper_bugoutconcepts_en_2025-11.zim",
"size_mb": 2890
},
{
"id": "urban-prepper_en_all",
"version": "2026-02",
"version": "2025-11",
"title": "Urban Prepper",
"description": "Comprehensive urban emergency preparedness video series",
"url": "https://download.kiwix.org/zim/videos/urban-prepper_en_all_2026-02.zim",
"url": "https://download.kiwix.org/zim/videos/urban-prepper_en_all_2025-11.zim",
"size_mb": 2240
}
]
@ -194,10 +194,10 @@
"resources": [
{
"id": "wikibooks_en_all_nopic",
"version": "2026-01",
"version": "2025-10",
"title": "Wikibooks",
"description": "Open-content textbooks covering math, science, computing, and more",
"url": "https://download.kiwix.org/zim/wikibooks/wikibooks_en_all_nopic_2026-01.zim",
"url": "https://download.kiwix.org/zim/wikibooks/wikibooks_en_all_nopic_2025-10.zim",
"size_mb": 3100
}
]
@ -210,35 +210,35 @@
"resources": [
{
"id": "ted_mul_ted-ed",
"version": "2026-01",
"version": "2025-07",
"title": "TED-Ed",
"description": "Educational video lessons on science, history, literature, and more",
"url": "https://download.kiwix.org/zim/ted/ted_mul_ted-ed_2026-01.zim",
"url": "https://download.kiwix.org/zim/ted/ted_mul_ted-ed_2025-07.zim",
"size_mb": 5610
},
{
"id": "wikiversity_en_all_maxi",
"version": "2026-02",
"version": "2025-11",
"title": "Wikiversity",
"description": "Tutorials, courses, and learning materials for all levels",
"url": "https://download.kiwix.org/zim/wikiversity/wikiversity_en_all_maxi_2026-02.zim",
"url": "https://download.kiwix.org/zim/wikiversity/wikiversity_en_all_maxi_2025-11.zim",
"size_mb": 2370
},
{
"id": "libretexts.org_en_math",
"version": "2026-01",
"version": "2025-01",
"title": "LibreTexts Mathematics",
"description": "Open-source math textbooks from algebra to calculus",
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_math_2026-01.zim",
"size_mb": 792
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_math_2025-01.zim",
"size_mb": 831
},
{
"id": "libretexts.org_en_phys",
"version": "2026-01",
"version": "2025-01",
"title": "LibreTexts Physics",
"description": "Physics courses and textbooks",
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_phys_2026-01.zim",
"size_mb": 534
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_phys_2025-01.zim",
"size_mb": 560
},
{
"id": "libretexts.org_en_chem",
@ -266,18 +266,18 @@
"resources": [
{
"id": "wikibooks_en_all_maxi",
"version": "2026-01",
"version": "2025-10",
"title": "Wikibooks (With Images)",
"description": "Open textbooks with full illustrations and diagrams",
"url": "https://download.kiwix.org/zim/wikibooks/wikibooks_en_all_maxi_2026-01.zim",
"url": "https://download.kiwix.org/zim/wikibooks/wikibooks_en_all_maxi_2025-10.zim",
"size_mb": 5400
},
{
"id": "ted_mul_ted-conference",
"version": "2026-02",
"version": "2025-08",
"title": "TED Conference",
"description": "Main TED conference talks on ideas worth spreading",
"url": "https://download.kiwix.org/zim/ted/ted_mul_ted-conference_2026-02.zim",
"url": "https://download.kiwix.org/zim/ted/ted_mul_ted-conference_2025-08.zim",
"size_mb": 16500
},
{
@ -290,11 +290,11 @@
},
{
"id": "libretexts.org_en_geo",
"version": "2026-01",
"version": "2025-01",
"title": "LibreTexts Geosciences",
"description": "Earth science, geology, and environmental studies",
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_geo_2026-01.zim",
"size_mb": 1127
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_geo_2025-01.zim",
"size_mb": 1190
},
{
"id": "libretexts.org_en_eng",
@ -306,11 +306,11 @@
},
{
"id": "libretexts.org_en_biz",
"version": "2026-01",
"version": "2025-01",
"title": "LibreTexts Business",
"description": "Business, economics, and management textbooks",
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_biz_2026-01.zim",
"size_mb": 801
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_biz_2025-01.zim",
"size_mb": 840
}
]
}
@ -331,18 +331,18 @@
"resources": [
{
"id": "woodworking.stackexchange.com_en_all",
"version": "2026-02",
"version": "2025-12",
"title": "Woodworking Q&A",
"description": "Stack Exchange Q&A for carpentry, joinery, and woodcraft",
"url": "https://download.kiwix.org/zim/stack_exchange/woodworking.stackexchange.com_en_all_2026-02.zim",
"url": "https://download.kiwix.org/zim/stack_exchange/woodworking.stackexchange.com_en_all_2025-12.zim",
"size_mb": 99
},
{
"id": "mechanics.stackexchange.com_en_all",
"version": "2026-02",
"version": "2025-12",
"title": "Motor Vehicle Maintenance Q&A",
"description": "Stack Exchange Q&A for car and motorcycle repair",
"url": "https://download.kiwix.org/zim/stack_exchange/mechanics.stackexchange.com_en_all_2026-02.zim",
"url": "https://download.kiwix.org/zim/stack_exchange/mechanics.stackexchange.com_en_all_2025-12.zim",
"size_mb": 321
}
]
@ -355,10 +355,10 @@
"resources": [
{
"id": "diy.stackexchange.com_en_all",
"version": "2026-02",
"version": "2025-12",
"title": "DIY & Home Improvement Q&A",
"description": "Stack Exchange Q&A for home repairs, electrical, plumbing, and construction",
"url": "https://download.kiwix.org/zim/stack_exchange/diy.stackexchange.com_en_all_2026-02.zim",
"url": "https://download.kiwix.org/zim/stack_exchange/diy.stackexchange.com_en_all_2025-12.zim",
"size_mb": 1900
}
]
@ -375,7 +375,7 @@
"title": "iFixit Repair Guides",
"description": "Step-by-step repair guides for electronics, appliances, and vehicles",
"url": "https://download.kiwix.org/zim/ifixit/ifixit_en_all_2025-12.zim",
"size_mb": 3380
"size_mb": 3570
}
]
}
@ -396,18 +396,18 @@
"resources": [
{
"id": "foss.cooking_en_all",
"version": "2026-02",
"version": "2025-11",
"title": "FOSS Cooking",
"description": "Quick and easy cooking guides and recipes",
"url": "https://download.kiwix.org/zim/zimit/foss.cooking_en_all_2026-02.zim",
"url": "https://download.kiwix.org/zim/zimit/foss.cooking_en_all_2025-11.zim",
"size_mb": 24
},
{
"id": "based.cooking_en_all",
"version": "2026-02",
"version": "2025-11",
"title": "Based.Cooking",
"description": "Simple, practical recipes from the community",
"url": "https://download.kiwix.org/zim/zimit/based.cooking_en_all_2026-02.zim",
"url": "https://download.kiwix.org/zim/zimit/based.cooking_en_all_2025-11.zim",
"size_mb": 16
}
]
@ -420,18 +420,18 @@
"resources": [
{
"id": "gardening.stackexchange.com_en_all",
"version": "2026-02",
"version": "2025-12",
"title": "Gardening Q&A",
"description": "Stack Exchange Q&A for growing your own food, plant care, and landscaping",
"url": "https://download.kiwix.org/zim/stack_exchange/gardening.stackexchange.com_en_all_2026-02.zim",
"url": "https://download.kiwix.org/zim/stack_exchange/gardening.stackexchange.com_en_all_2025-12.zim",
"size_mb": 923
},
{
"id": "cooking.stackexchange.com_en_all",
"version": "2026-02",
"version": "2025-12",
"title": "Cooking Q&A",
"description": "Stack Exchange Q&A for cooking techniques, food safety, and recipes",
"url": "https://download.kiwix.org/zim/stack_exchange/cooking.stackexchange.com_en_all_2026-02.zim",
"url": "https://download.kiwix.org/zim/stack_exchange/cooking.stackexchange.com_en_all_2025-12.zim",
"size_mb": 236
},
{
@ -485,18 +485,18 @@
"resources": [
{
"id": "freecodecamp_en_all",
"version": "2026-02",
"version": "2025-11",
"title": "freeCodeCamp",
"description": "Interactive programming tutorials - JavaScript, algorithms, and data structures",
"url": "https://download.kiwix.org/zim/freecodecamp/freecodecamp_en_all_2026-02.zim",
"url": "https://download.kiwix.org/zim/freecodecamp/freecodecamp_en_all_2025-11.zim",
"size_mb": 8
},
{
"id": "devdocs_en_python",
"version": "2026-02",
"version": "2026-01",
"title": "Python Documentation",
"description": "Complete Python language reference and tutorials",
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_python_2026-02.zim",
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_python_2026-01.zim",
"size_mb": 4
},
{
@ -533,26 +533,26 @@
"resources": [
{
"id": "arduino.stackexchange.com_en_all",
"version": "2026-02",
"version": "2025-12",
"title": "Arduino Q&A",
"description": "Stack Exchange Q&A for Arduino microcontroller projects",
"url": "https://download.kiwix.org/zim/stack_exchange/arduino.stackexchange.com_en_all_2026-02.zim",
"url": "https://download.kiwix.org/zim/stack_exchange/arduino.stackexchange.com_en_all_2025-12.zim",
"size_mb": 247
},
{
"id": "raspberrypi.stackexchange.com_en_all",
"version": "2026-02",
"version": "2025-12",
"title": "Raspberry Pi Q&A",
"description": "Stack Exchange Q&A for Raspberry Pi projects and troubleshooting",
"url": "https://download.kiwix.org/zim/stack_exchange/raspberrypi.stackexchange.com_en_all_2026-02.zim",
"url": "https://download.kiwix.org/zim/stack_exchange/raspberrypi.stackexchange.com_en_all_2025-12.zim",
"size_mb": 285
},
{
"id": "devdocs_en_node",
"version": "2026-02",
"version": "2026-01",
"title": "Node.js Documentation",
"description": "Node.js API reference and guides",
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_node_2026-02.zim",
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_node_2026-01.zim",
"size_mb": 1
},
{
@ -581,18 +581,18 @@
"resources": [
{
"id": "electronics.stackexchange.com_en_all",
"version": "2026-02",
"version": "2025-12",
"title": "Electronics Q&A",
"description": "Stack Exchange Q&A for circuit design, components, and electrical engineering",
"url": "https://download.kiwix.org/zim/stack_exchange/electronics.stackexchange.com_en_all_2026-02.zim",
"url": "https://download.kiwix.org/zim/stack_exchange/electronics.stackexchange.com_en_all_2025-12.zim",
"size_mb": 3800
},
{
"id": "robotics.stackexchange.com_en_all",
"version": "2026-02",
"version": "2025-12",
"title": "Robotics Q&A",
"description": "Stack Exchange Q&A for robotics projects and automation",
"url": "https://download.kiwix.org/zim/stack_exchange/robotics.stackexchange.com_en_all_2026-02.zim",
"url": "https://download.kiwix.org/zim/stack_exchange/robotics.stackexchange.com_en_all_2025-12.zim",
"size_mb": 233
},
{

View File

@ -1,5 +1,5 @@
{
"spec_version": "2026-03-23",
"spec_version": "2026-02-11",
"options": [
{
"id": "none",
@ -37,7 +37,7 @@
"id": "all-nopic",
"name": "Complete Wikipedia (No Images)",
"description": "All articles without images. Comprehensive offline reference.",
"size_mb": 49000,
"size_mb": 25000,
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_nopic_2025-12.zim",
"version": "2025-12"
},
@ -45,9 +45,9 @@
"id": "all-maxi",
"name": "Complete Wikipedia (Full)",
"description": "The complete experience with all images and media.",
"size_mb": 118000,
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_maxi_2026-02.zim",
"version": "2026-02"
"size_mb": 102000,
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_maxi_2024-01.zim",
"version": "2024-01"
}
]
}

View File

@ -31,6 +31,8 @@ GREEN='\033[1;32m' # Light Green.
WHIPTAIL_TITLE="Project N.O.M.A.D Installation"
NOMAD_DIR="/opt/project-nomad"
MANAGEMENT_COMPOSE_FILE_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/management_compose.yaml"
SIDECAR_UPDATER_DOCKERFILE_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/sidecar-updater/Dockerfile"
SIDECAR_UPDATER_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/sidecar-updater/update-watcher.sh"
START_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/start_nomad.sh"
STOP_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/stop_nomad.sh"
UPDATE_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/update_nomad.sh"
@ -401,15 +403,6 @@ download_management_compose_file() {
local db_root_password=$(generateRandomPass)
local db_user_password=$(generateRandomPass)
# If MySQL data directory exists from a previous install attempt, remove it.
# MySQL only initializes credentials on first startup when the data dir is empty.
# If stale data exists, MySQL ignores the new passwords above and uses the old ones,
# causing "Access denied" errors when the admin container tries to connect.
if [[ -d "${NOMAD_DIR}/mysql" ]]; then
echo -e "${YELLOW}#${RESET} Removing existing MySQL data directory to ensure credentials match...\\n"
sudo rm -rf "${NOMAD_DIR}/mysql"
fi
# Inject dynamic env values into the compose file
echo -e "${YELLOW}#${RESET} Configuring docker-compose file env variables...\\n"
sed -i "s|URL=replaceme|URL=http://${local_ip_address}:8080|g" "$compose_file_path"
@ -422,6 +415,32 @@ download_management_compose_file() {
echo -e "${GREEN}#${RESET} Docker compose file configured successfully.\\n"
}
download_sidecar_files() {
# Create sidecar-updater directory if it doesn't exist
if [[ ! -d "${NOMAD_DIR}/sidecar-updater" ]]; then
sudo mkdir -p "${NOMAD_DIR}/sidecar-updater"
sudo chown "$(whoami):$(whoami)" "${NOMAD_DIR}/sidecar-updater"
fi
local sidecar_dockerfile_path="${NOMAD_DIR}/sidecar-updater/Dockerfile"
local sidecar_script_path="${NOMAD_DIR}/sidecar-updater/update-watcher.sh"
echo -e "${YELLOW}#${RESET} Downloading sidecar updater Dockerfile...\\n"
if ! curl -fsSL "$SIDECAR_UPDATER_DOCKERFILE_URL" -o "$sidecar_dockerfile_path"; then
echo -e "${RED}#${RESET} Failed to download the sidecar updater Dockerfile. Please check the URL and try again."
exit 1
fi
echo -e "${GREEN}#${RESET} Sidecar updater Dockerfile downloaded successfully to $sidecar_dockerfile_path.\\n"
echo -e "${YELLOW}#${RESET} Downloading sidecar updater script...\\n"
if ! curl -fsSL "$SIDECAR_UPDATER_SCRIPT_URL" -o "$sidecar_script_path"; then
echo -e "${RED}#${RESET} Failed to download the sidecar updater script. Please check the URL and try again."
exit 1
fi
chmod +x "$sidecar_script_path"
echo -e "${GREEN}#${RESET} Sidecar updater script downloaded successfully to $sidecar_script_path.\\n"
}
download_helper_scripts() {
local start_script_path="${NOMAD_DIR}/start_nomad.sh"
local stop_script_path="${NOMAD_DIR}/stop_nomad.sh"
@ -491,7 +510,7 @@ verify_gpu_setup() {
fi
# Check if Docker has NVIDIA runtime
if docker info 2>/dev/null | grep -q "nvidia"; then
if docker info 2>/dev/null | grep -q \"nvidia\"; then
echo -e "${GREEN}${RESET} Docker NVIDIA runtime configured\\n"
else
echo -e "${YELLOW}${RESET} Docker NVIDIA runtime not detected\\n"
@ -507,11 +526,11 @@ verify_gpu_setup() {
echo -e "${YELLOW}===========================================${RESET}\\n"
# Summary
if command -v nvidia-smi &> /dev/null && docker info 2>/dev/null | grep -q "nvidia"; then
if command -v nvidia-smi &> /dev/null && docker info 2>/dev/null | grep -q \"nvidia\"; then
echo -e "${GREEN}#${RESET} GPU acceleration is properly configured! The AI Assistant will use your GPU.\\n"
else
echo -e "${YELLOW}#${RESET} GPU acceleration not detected. The AI Assistant will run in CPU-only mode.\\n"
if command -v nvidia-smi &> /dev/null && ! docker info 2>/dev/null | grep -q "nvidia"; then
if command -v nvidia-smi &> /dev/null && ! docker info 2>/dev/null | grep -q \"nvidia\"; then
echo -e "${YELLOW}#${RESET} Tip: Your GPU is detected but Docker runtime is not configured.\\n"
echo -e "${YELLOW}#${RESET} Try restarting Docker: ${WHITE_R}sudo systemctl restart docker${RESET}\\n"
fi
@ -547,6 +566,7 @@ check_docker_compose
setup_nvidia_container_toolkit
get_local_ip
create_nomad_directory
download_sidecar_files
download_helper_scripts
download_management_compose_file
start_management_containers

View File

@ -23,7 +23,6 @@ services:
- nomad-update-shared:/app/update-shared # Shared volume for update communication
environment:
- NODE_ENV=production
# PORT is the port the admin server listens on *inside* the container and should not be changed. If you want to change which port the admin interface is accessible from on the host, you can change the port mapping in the "ports" section (e.g. "9090:8080" to access it on port 9090 from the host)
- PORT=8080
- LOG_LEVEL=info
# APP_KEY needs to be at least 16 chars or will fail validation and container won't start!
@ -82,7 +81,7 @@ services:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 30s
timeout: 10s
retries: 10
retries: 3
redis:
image: redis:7-alpine
container_name: nomad_redis
@ -117,4 +116,4 @@ services:
volumes:
nomad-update-shared:
driver: local
driver: local

View File

@ -40,10 +40,6 @@ while true; do
[[ "$fstype" =~ ^(tmpfs|devtmpfs|squashfs|sysfs|proc|devpts|cgroup|cgroup2|overlay|nsfs|autofs|hugetlbfs|mqueue|pstore|fusectl|binfmt_misc)$ ]] && continue
[[ "$mountpoint" == "none" ]] && continue
# Skip Docker bind-mounts to individual files (e.g., /etc/resolv.conf, /etc/hostname, /etc/hosts)
# These are not real filesystem roots and report misleading sizes
[[ -f "/host${mountpoint}" ]] && continue
STATS=$(df -B1 "/host${mountpoint}" 2>/dev/null | awk 'NR==2{print $2,$3,$4,$5}')
[[ -z "$STATS" ]] && continue

View File

@ -1,6 +1,6 @@
{
"name": "project-nomad",
"version": "1.30.3",
"version": "1.30.0-rc.1",
"description": "\"",
"main": "index.js",
"scripts": {