Compare commits

..

54 Commits

Author SHA1 Message Date
jakeaturner bcee7529a0
chore: force bump main version 2026-06-09 19:03:49 +00:00
jakeaturner 4922e89fd7
docs: update release notes 2026-06-09 19:02:05 +00:00
jakeaturner 370495a786 fix(bullmq): bump to 5.77.6 and update set calls w new args shape 2026-06-09 11:29:59 -07:00
jakeaturner a20d424d2c chore(deps): bump autoprefixer 2026-06-09 11:18:35 -07:00
jakeaturner d239894c2f chore(deps): bump react and react-dom 2026-06-09 11:17:11 -07:00
jakeaturner 33138839b4 feat(content): opt-in automatic updates for installed ZIM & map content 2026-06-09 09:46:21 -07:00
akashsalan 288592478b fix(system): prevent false offline reports when Cloudflare endpoint is unreachable 2026-06-09 00:54:10 -07:00
Chris Sherwood 11baa56d6d fix(content): remove superseded curated map/ZIM files when a new version installs
Only Wikipedia had version cleanup; every other curated map and non-Wikipedia
ZIM left its prior version on disk when a newer one installed, so users silently
accumulated orphaned content (potentially hundreds of GB). (#634)

The install paths already record each resource via InstalledResource
{resource_id, resource_type, version, file_path}, so the authoritative old-file
path for a resource is known. On install of a new version we now capture the
prior row before updateOrCreate repoints it, then delete the old file — gated
behind a pure, fully unit-tested decision function with strict safety rails:

  - tracked-only: requires a prior InstalledResource row for the same
    resource_id, so sideloaded/untracked files are never touched
  - genuine replacement: old and new file paths must differ
  - new-file-verified: the new file must be confirmed on disk first
  - strictly-newer: a re-install or downgrade can't wipe a newer file
  - within-storage-dir: the old path must resolve under the content store

ZIM cleanup deletes the old file directly (NOT via this.delete(), which would
drop the InstalledResource row by resource_id that updateOrCreate just
repointed) and rebuilds the Kiwix library only if a file was actually removed,
so its XML never references a deleted ZIM. Maps need no library step. Wikipedia
keeps its own existing cleanup path. All deletions are best-effort and logged;
a failure never breaks the install.

Closes #634

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 00:52:04 -07:00
John Onysko 2d3e76a2b2 fix(chat): prefer selected model for suggestions, fall back to smallest
`getChatSuggestions` previously picked the largest installed model by file
size, on the assumption that bigger models give better suggestions. This
is unsafe: if any installed model exceeds available VRAM (e.g.
llama3.1:405b on a 96 GB GPU), Ollama spends minutes trying to load it
and the request 500s — making the chat page unusable for anyone who
happens to keep a flagship-sized model on disk.

Chat suggestions are short prompts that don't benefit from a flagship
model anyway. Prefer the user's selected `chat.lastModel` when set, and
fall back to the smallest installed model otherwise. `OllamaService.getModels()`
already excludes embedders, so the fallback always picks a chat model.
2026-06-08 19:37:27 -07:00
jakeaturner 3e4e31cb89 refactor(supply-depot): extract version and subtitle helpers instead of IIFE for readability 2026-06-08 19:32:46 -07:00
Chris Sherwood 63a87d512d feat(supply-depot): show installed version on cards + make Update pill stand out
Two card tweaks for the update workflow:

- Show the installed image tag next to the powered-by name (e.g. "Kiwix ·
  3.7.0"), so the running version is visible at a glance. Only rendered for
  installed apps; falls back to just the version when powered_by is unset.
- Change the "Update available" pill from a muted light-green tint to a solid
  desert-orange fill with white text, so an available update actually draws
  the eye instead of blending into the card.
2026-06-08 19:32:46 -07:00
Chris Sherwood 673c12b96e fix(updates): resolve relative registry pagination URL so tag listing doesn't crash (#945)
listTags() follows the registry's Link-header pagination, but the next-page
URL is relative per the OCI/Docker registry spec (e.g.
"/v2/ollama/ollama/tags/list?last=0.9.3-rc5&n=1000"). The code assigned that
raw relative path straight back to `url` and re-fetched it, so fetch() threw
"Failed to parse URL from /v2/...". Any image repo with more than 1000 tags
paginates, so the entire tag list — and therefore the update check — failed
silently for ollama/ollama and filebrowser/filebrowser.

That's the root cause of #945 ("won't update past 0.24.0"): the Ollama
update check never completed, so no newer version was ever offered.

Resolve the next-page URL against the registry origin with
new URL(next, `https://${registry}`), which also passes absolute next-URLs
through unchanged for registries that return those.

Closes #945
2026-06-08 19:08:03 -07:00
Chris Sherwood 5227175932 feat(maps): persist map view across refresh
The maps page reset to the default US-wide view on every refresh because
initialViewState was hardcoded. Save the position and zoom to localStorage
on each move-end (key nomad:map-view, matching the existing scale-unit
pattern) and restore it at mount: saved view → default. The restore is
bounds-checked so a corrupt value falls through to the default.

Replaces #815, whose branch had drifted far out of scope (56 files of
stale-base/merged-commit noise plus unrelated map-feature WIP). This is
just the persist-view improvement, ported cleanly onto current dev. The
null-island and coordinate-search parts of #815 targeted URL-param code
that never landed on dev, so they don't apply here.
2026-06-08 19:01:14 -07:00
Chris Sherwood ed1867abdb fix(maps): dedupe map sources by region so duplicate files don't blank the map
The map style names each source by its date-stripped region (both
"washington.pmtiles" and "washington_2025-12.pmtiles" -> "washington").
When an old and new copy of the same region are both on disk, the style
emitted two sources with the same key and duplicate layer ids, which
MapLibre rejects outright -- blanking the ENTIRE map, not just that region.

Old copies linger when a newer curated version installs (#634), so a user
who updates maps can silently lose all map rendering until the stale file
is removed by hand.

generateSourcesArray() now keeps only the newest file per region: a dated
build beats an undated legacy file, and between two dated builds the later
YYYY-MM wins. The skipped duplicate is logged. The style stays valid even
when stale files are present.

Complements #981, which removes superseded curated files on install. This
is the runtime safety net that also recovers installs already in the broken
state (which a cleanup-on-install alone can't reach).

Refs #634
2026-06-08 18:57:33 -07:00
Chris Sherwood ebce165272 fix(AI): truncate-and-retry oversized embed chunks; stop 30x retry storm (#881)
Dense source content produces chunks that exceed the embedding model's
context window (nomic-embed-text:v1.5 defaults to 2048 tokens). Two paths
hit this even after the prior pre-cap:

  - Older Ollama (e.g. 0.18.1, #944) ignores the num_ctx=8192 we send on
    /api/embed, so it stays at the model's 2048 default.
  - The OpenAI-compat /v1/embeddings fallback didn't pass num_ctx/truncate
    at all, so any Ollama drops to 2048 whenever it lands on the fallback.

When a chunk overflowed, the 400 was swallowed and the chunk was silently
dropped from Qdrant. Worse, the failure propagated to EmbedFileJob, which
re-embeds the entire file on each of its 30 BullMQ attempts — the "endless
queue loop" / "api/embed for weeks" / pegged GPU reported in #944/#959.

Fix:
  - OllamaService.embed(): on a context-length error, retry once with an
    aggressive 2048-safe cap (EMBED_CONTEXT_SAFE_CHARS = 2000) so the chunk
    is embedded (start-of-chunk) instead of dropped. Native-path context
    errors now bubble to this retry instead of falling through to the
    smaller-context fallback. Split the native+fallback attempt into
    _embedWithFallback().
  - Pass truncate/num_ctx on the /v1/embeddings fallback too (Ollama's
    OpenAI-compat shim forwards them).
  - EmbedFileJob: classify "input length exceeds context length" as an
    UnrecoverableError so one permanently-oversized chunk can't trigger 30
    full-file re-embeds.
  - Add OllamaService.isContextLengthError() shared by both.

Graceful degradation: a truncated chunk loses its tail but is kept in the
index, which is strictly better than today's silent drop + retry storm.

Refs #881. Supersedes the #369/#670 symptom closures that never fixed the
fallback path.
2026-06-08 18:52:54 -07:00
Chris Sherwood 6418961061 fix(content): narrow Wikipedia reconcile-skip to the managed selection file
reconcileFromFilesystem() skipped every ZIM whose filename starts with
`wikipedia_en_`, on the assumption that all such files are managed by the
WikipediaSelection model. But curated category tiers ship Wikipedia-themed
ZIMs (e.g. Medicine → Comprehensive includes `wikipedia_en_medicine_maxi`),
so those files were skipped during reconcile and their InstalledResource
rows got wiped on every restart — silently downgrading the detected tier.

Skip only the single file actually tracked by WikipediaSelection, matched
by exact filename instead of the `wikipedia_en_` prefix.

Reimplemented in-house from @johno10661's PR #774 (which was trapped on a
stale base); credit to them for the diagnosis and fix.

Closes #774
2026-06-08 18:24:01 -07:00
Chris Sherwood 009e12fdd7 fix(system): disable Update button while a service update is in flight (#931)
A multi-GB service update (e.g. nomad_ollama pulling ~6.5 GB) left the
Update button clickable with no feedback, so users clicked again thinking
it was stuck. The second click raced a concurrent updateContainer run into
Docker 304/400 errors (stop/rename on a container the first run had already
moved). The backend lock was in-memory only and never written to the DB, so
nothing durable signaled "update in progress" to the UI, and a page reload
mid-pull re-enabled the button.

Backend (docker_service.updateContainer):
- Set installation_status='installing' when the update starts and reset it
  to 'idle' in a finally on every exit path. This mirrors the install path,
  survives a page reload, and is visible to other tabs/clients.
- Reject a second update with a clear message when installation_status is
  already 'installing', instead of letting it race into Docker errors.

Frontend (settings/apps.tsx):
- Track in-flight updates per service. Seed optimistically on click and
  reconcile with the durable installation_status from the server.
- Disable the per-service Update button and show "Updating..." while in
  flight. Drop the fullscreen spinner for updates so the table and the
  activity feed (live pull/stop/start progress) stay visible.

Closes #931
2026-06-08 15:54:59 -07:00
Chris Sherwood 9bc846d97e fix(kiwix): self-heal a missing or corrupt library XML on startup
Kiwix runs in library mode reading kiwix-library.xml via --monitorLibrary.
Today a missing or corrupt XML is only repaired on the download path
(rebuildFromDisk after a completed download), so if the file is lost or
truncated outside that flow — storage relocation, an interrupted write, manual
deletion — Kiwix comes up serving an empty library with no path to recovery.

Add KiwixLibraryService.ensureLibraryXmlHealthy(): reads the XML, and if it's
missing (ENOENT) or fails to parse / lacks a <library> root, rebuilds it from
the ZIM files on disk. A well-formed but empty library is treated as valid (no
spurious rebuild), and filesystem errors other than ENOENT are surfaced rather
than masked. The boot provider calls it on the already-in-library-mode path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:45:06 -07:00
jakeaturner ad3a6c5ca8 feat(supply-depot): add custom launch URLs for apps
Let users override an app's "Open" link with a reverse-proxy or
local-DNS address (e.g. https://jellyfin.myhomelab.net). Falls back to
the default host+port when unset. Metadata-only — no container changes.
2026-06-08 15:35:44 -07:00
Chris Sherwood 21be4df7d1 fix(security): harden assertNotPrivateUrl with ipaddr.js + host normalization
Replaces the regex blocklist in assertNotPrivateUrl with ipaddr.js range
classification and normalizes the host before checking it. Consolidates two
community proposals (#930 ipaddr.js parsing, #912 trailing-dot normalization)
into one validator so the SSRF-critical path lives in-house with full tests.

- Classify literal IPs by range (loopback / linkLocal / unspecified) via
  ipaddr.js instead of a hand-maintained regex list, which also catches
  alternate IPv4 encodings and avoids over-blocking mapped public IPs (the old
  `::ffff:` regex blocked every mapped address, including public ones). IPv4-
  mapped IPv6 is reduced to its embedded IPv4 before classification.
- Strip a trailing root dot from the host so `localhost.` / `127.0.0.1.` can't
  bypass the checks (they resolve to the same target as the dotless form, #911).
- Strip IPv6 brackets and lowercase for the localhost comparison.
- RFC1918, bare LAN hostnames (e.g. `nomad3`), and external FQDNs remain
  allowed — LAN appliances need them, and DNS rebinding is a fetch-time concern
  outside this guard's scope.

Adds a consolidated unit spec covering loopback/link-local/unspecified literals,
alternate encodings, IPv4-mapped v6, mixed-case + trailing-dot localhost, and
the allowed LAN/FQDN/mapped-public cases.

Resolves #922. Supersedes #930 and #912 (thanks @Gujiassh and @luyua9).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:34:17 -07:00
Chris Sherwood eb2e41d0ba fix(docker): reject failed image pulls instead of treating them as success
Every Docker pull went through `followProgress(pullStream, resolve)`, passing
the Promise's resolve as dockerode's onFinished(err, output) callback — so the
error argument was ignored and a failed pull (dropped/metered connection, bad
manifest, registry error, disk full mid-pull) resolved as if it had succeeded.
The code then tried to create/start a container from a missing or partial
image, surfacing a confusing downstream error rather than the real cause. (#790)

Add a DockerService.pullImage() helper that rejects when followProgress reports
an error, and route all five pull sites through it:
  - service install
  - AMD ROCm image pull
  - service update
  - force-reinstall / recreate (forcePull)
  - sysbench benchmark image pull

Closes #790

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:27:46 -07:00
Chris Sherwood ea72ebbd9b fix(KB): stop ZIM ingestion progress freezing at 99% on multi-page archives
On ZIMs that pack one logical article as several sub-pages (e.g. iFixit),
iterByPath yields more entries passing our isArticleEntry() filter than
archive.articleCount reports. The inter-batch progress used nextOffset /
articleCount, so the numerator outran the denominator, the ratio overflowed past
100%, and the UI (which clamps at 99%) pinned the file at 99% for the entire
remaining tail, making it look hung.

Grow the denominator to max(articleCount, nextOffset + ZIM_BATCH_SIZE) once we
pass the reported article count, so progress keeps creeping forward monotonically
instead of freezing, and clamp to 99% so only the genuinely-final batch reports
100%.

This is a graceful heuristic, not exact progress (true accuracy would require a
pre-scan to count isArticleEntry matches up front); it removes the user-visible
"stuck at 99%" symptom with no change to batch semantics.

Closes #903

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:29:32 -07:00
Chris Sherwood abed40a8c4 fix(KB): stop partial_stall warning firing on atypical ZIMs (link-out/PDF-heavy)
The Stored Files "partial stall" warning compares chunks in Qdrant against an
expected count from the ratio registry. The registry has an empty-pattern
catch-all (100 chunks/MB) that matches any filename, so a ZIM that matches no
specific pattern still gets a size-based estimate. For archives that are mostly
PDFs, images, or link-out stubs (e.g. irp.fas.org military-medicine), byte size
wildly over-predicts embeddable text: a 75 MB ZIM estimates ~7,236 chunks but
produces ~1, tripping a false "ingestion may have stalled" warning that re-embed
can't clear.

The catch-all is fine for rough aggregate disk-cost estimates, but it should not
drive a per-file stall signal. Add an `ignoreCatchAll` option to the ratio
lookup that excludes the empty-pattern row (returning null when only the
fallback would match), and use it in the warnings path so partial_stall only
fires when the registry has a *specific* expectation for the file. Files that
match a real pattern (wikipedia_, devdocs_, ifixit_, ...) are unaffected;
disk-cost/batch estimates keep using the fallback.

Closes #913

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:23:40 -07:00
Chris Sherwood 4ad7e84a19 fix(system): roll back service update when the new container fails to start
The service update path stopped and renamed the old container aside before
the new one was confirmed running, but only wired up rollback if
createContainer threw or the 5s health check failed. A throw from
newContainer.start() itself (bad device/GPU config, host port already bound,
image incompatibility) bubbled straight to the outer catch, which returned a
generic 400 and never restored the old container, leaving the service down.

Retrying then wedged: the failed new container still held the service name, so
the next attempt's rename to `<name>_old` collided with the leftover from the
first attempt and threw the same error every time.

- Wrap newContainer.start() so a start failure removes the half-created
  container and rolls back to the previous one.
- Clear any stale `<name>_old` before renaming so retries can't collide.
- Dedupe the three rollback sites into a single rollbackToOld() helper (also
  removes a non-null assertion in the create-failure path that could itself
  throw when no `_old` existed).

Refs #949

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:19:04 -07:00
jakeaturner 1430282e0d docs: fix out of place JSDoc comment 2026-06-08 11:04:12 -07:00
Chris Sherwood 034ad41ef4 fix(system): show a clear message when a service port is already in use
When a service fails to install because something on the host already binds its
port, the user saw the raw dockerode error ("Bind for 0.0.0.0:11434 failed:
port is already allocated"), which is meaningless to a non-technical user. The
most common case is a native Ollama install holding 11434.

Add _humanizeDockerError() to map host port-conflict errors to an actionable
message that names the port and, for Ollama/11434, points at the likely cause
(a host Ollama service) with the commands to stop it. Unrecognized errors pass
through unchanged. Wired into the install failure broadcast and the thrown
error.

Closes #934

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:04:12 -07:00
jakeaturner 52608d9435 feat(supply-depot): opt-in automatic updates for installed apps 2026-06-07 15:56:42 -07:00
jakeaturner b2fd2b6585 feat(system): add opt-in automatic updates for the core NOMAD app
Adds a self-update path for the NOMAD admin/core image that runs without a
human in the loop, gated by layered safety checks. Recreation remains the
sidecar's job; this only decides *whether* to update now and requests it.

An hourly job (AutoUpdateJob) evaluates a side-effect-free decision pipeline
(AutoUpdateService.evaluate):
 - opt-in: disabled by default
 - in a user-configured local-time window (handles midnight wrap)
 - an eligible release: same major (major bumps stay manual), strictly newer,
   past a configurable cool-off, GA only (no drafts/prereleases), strict semver
 - pre-flight: sidecar present, no in-flight system update / downloads / app
   installs, and sufficient host disk (estimated from the registry manifest)
When all pass, it drives SystemUpdateService.requestUpdate() with a vetted tag.

Failure backoff auto-disables after 3 genuine update-request failures; transient
release-lookup errors are skips (offline-first appliances are routinely without
connectivity). Re-enabling clears the backoff state.

Settings UI exposes the toggle, window, and cool-off, with live status (eligible
target, in/out of window, last result/error). A `node ace auto-update:dry-run`
command exercises the full pipeline — and a deterministic --scenarios suite the
pure decision logic — without ever triggering an update.

New KVStore keys under autoUpdate.* hold config + last-run state.
2026-06-07 15:52:56 -07:00
Metbcy 92a026816f fix(KB): persist accumulated chunk count across batched ZIM dispatches
The continuation dispatch in EmbedFileJob did not pass the running chunk
count forward, so each batch started with job.data.chunks undefined.
On the final batch, totalChunks collapsed to just that batch's result
and KbIngestState.markIndexed stored a value far below what Qdrant
actually held.

Add chunksSoFar to EmbedFileJobParams and thread it through the
continuation chain so the final markIndexed call reflects the true
total across all batches.

Closes #933
2026-06-07 15:27:49 -07:00
John Onysko 9c9b257584 feat(config): respect REDIS_DB env var for queue and transmit
Allow operators to select a Redis logical database index via the
REDIS_DB environment variable. Without this, the BullMQ queue and the
@adonisjs/transmit Redis transport both implicitly used db 0, causing
key collisions when sharing a Redis instance across multiple services
or environments.

REDIS_DB is added to the env schema as an optional number; both
config/queue.ts and config/transmit.ts fall back to db 0 when unset,
preserving existing behavior.
2026-06-07 15:11:22 -07:00
gujishh 69be488b22 fix(install): harden toolkit and helper script downloads 2026-06-07 15:06:24 -07:00
Chris Sherwood 814d100115 chore: align package license with Apache-2.0 and fix README docs links/typos
Correct package metadata and documentation accuracy:

- Set license to Apache-2.0 in package.json, admin/package.json and both
  lockfiles (project has been Apache-2.0 since #197; metadata still said ISC)
- Replace the placeholder root package.json description with a real one
- Remove the dead README "Troubleshooting Guide" link (TROUBLESHOOTING.md
  does not exist; FAQ.md already has its own entry)
- Fix README typos: "harware", "LLM's", "of of", "uses cases"

Carries forward the worthwhile fixes from #849 by @aqilaziz, minus the
version bump that conflicted with the current release line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:57:26 -07:00
jakeaturner 3dff62f498 fix(storage): match default prefix too when relocating child-app binds
_applyHostStorageRoot only rewrote binds carrying the current
NOMAD_STORAGE_PATH and short-circuited when env == resolved root. That
left user-modified/custom apps — whose binds freeze the default prefix at
edit time — mounting an empty dir on the documented "set NOMAD_STORAGE_PATH
+ relocate the volume" path (#938).

- Match either the env value or the hardcoded default prefix; drop the
  root == seededRoot no-op, excluding root from the candidates instead so
  rewrites stay idempotent.
- Add ADMIN_STORAGE_DEST / DEFAULT_HOST_STORAGE_ROOT constants; replace the
  cwd-derived storage dest so admin-mount lookup can't silently break.
- Comment why a transient inspect failure is deliberately not cached.
2026-06-07 14:56:05 -07:00
Chris Sherwood c7dfc75c83 fix(storage): derive child-app bind paths from the admin's actual storage mount
Child services (Kiwix, Ollama, Qdrant, Flatnotes, Kolibri) are created via the
Docker socket, so their bind mounts use a HOST path. That path was baked into
the services table at seed time from NOMAD_STORAGE_PATH (default
/opt/project-nomad/storage) — and NOMAD_STORAGE_PATH wasn't even present in
management_compose.yaml, so it always fell back to the default.

Result: relocating the admin storage volume in compose (e.g.
/mnt/big/storage:/app/storage) moved the admin's own data but left child apps
mounting the old, now-empty /opt/project-nomad/storage. Kiwix would come up
with no content. (#938)

- Add _resolveHostStorageRoot(): inspect the admin's own container, find the
  bind backing /app/storage, and use its host Source as the single source of
  truth (cached). Falls back to NOMAD_STORAGE_PATH/default if it can't be
  inspected.
- Add _applyHostStorageRoot(): rewrite the host-side prefix of each storage bind
  to that root. No-op when it already matches the seeded prefix, so default
  installs are unaffected.
- Apply it in _createContainer (covers installs + dependency recursion) and in
  the Kiwix library-mode recreate path.
- management_compose.yaml: add an explicit NOMAD_STORAGE_PATH knob and rewrite
  the comments so the admin volume, env var, and disk-collector volume that must
  agree are spelled out.

Closes #938

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:56:05 -07:00
jakeaturner 028072e9a3
fix: minor type guarding fixes after axios bump 2026-06-07 21:18:42 +00:00
jakeaturner 97e17b8664 fix(supply-depot): reintroduce app update UI 2026-06-06 11:08:52 -07:00
Chris Sherwood 2110dc1005 feat(supply-depot): per-app onboarding docs, install fixes, and in-app Docs links
Add NOMAD-specific getting-started docs for all 9 curated Supply Depot apps,
the catalog/install fixes each one surfaced, and a way to reach the docs from
each app card.

Docs:
- New in-app Markdoc page admin/docs/supply-depot-apps.md covering all 9 apps
  (Stirling PDF, File Browser, Calibre-Web, IT Tools, Excalidraw, Homebox,
  Vaultwarden, Jellyfin, Meshtastic Web): first run/login, where data lives,
  and offline behaviour. Registered in docs_service DOC_ORDER.
- Manage > Docs dropdown item linking each app to its section
  (/docs/supply-depot-apps#<anchor>): anchor map in constants/supply_depot_docs.ts,
  heading anchors via Markdoc {% #id %}, and hash-scroll on the docs page.

Install / catalog fixes:
- Stirling PDF: open straight to the tools (SECURITY_ENABLELOGIN=false; the old
  v1 DOCKER_ENABLE_SECURITY flag was dead).
- File Browser: seed a known admin/nomad login (bcrypt) instead of a random
  log-only password; scope visibility to content folders via mount selection and
  move the DB out of the browsable root.
- Calibre-Web: bundle an empty Calibre library and seed it on install so setup
  doesn't dead-end at db config (_runPreinstallActions__CalibreWeb).
- Homebox: swap the archived hay-kot image for the maintained sysadminsmedia fork.
- Vaultwarden: generate a self-signed cert on install and serve HTTPS by default
  (_runPreinstallActions__Vaultwarden + ROCKET_TLS + ui_location https:8480), so
  the web vault has the secure context it requires.
- Jellyfin: pre-create storage/media/{Movies,TV Shows,Music,Photos} so each
  library points at its own subfolder, avoiding the overlapping-path issue that
  silently hides content (_runPreinstallActions__Jellyfin).
- Seeder run() now also syncs ui_location for non-modified curated services, so a
  catalog link/scheme/port change reaches existing installs on update.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:08:52 -07:00
Chris Sherwood 5b692cc7c2 fix(supply-depot): show clean port + lock on card pill for https:port ui_location 2026-06-06 11:08:52 -07:00
Chris Sherwood f89c2ed060 feat(supply-depot): scheme-aware service links (https:port) for TLS-serving apps 2026-06-06 11:08:52 -07:00
Chris Sherwood 038f9c796e fix(supply-depot): correct Meshtastic Web internal port (80->8080); add catalog port audit script 2026-06-06 11:08:52 -07:00
Chris Sherwood a16c70dbe2 feat: edit curated apps + fix dropdown clip + stale _old rollback 2026-06-06 11:08:52 -07:00
Jake Turner daf3050e59 feat: supply depot 2026-06-06 11:08:52 -07:00
Chris Sherwood 3aab9f29ec feat(zim): add "Rescan Library" button for sideloaded ZIM files
Adds a user-facing trigger to rebuild the Kiwix library index from the
ZIM files currently on disk. Covers the sideload case: a user copies a
.zim onto the box (USB, SSH, network share) outside NOMAD's download
flow, and Kiwix has no way to discover it without regenerating the
library index.

Reuses the existing native KiwixLibraryService.rebuildFromDisk() (which
also extracts embedded favicons natively), so no kiwix-tools container
and no icon-patch step are needed. In library mode (--monitorLibrary)
kiwix-serve hot-reloads the XML automatically; only legacy glob-mode
containers are restarted.

- KiwixLibraryService.rebuildFromDisk now returns the book count; adds
  getBookCount() for the before/after delta
- ZimService.rescanLibrary() orchestrates rebuild + legacy restart
- POST /api/zim/rescan-library + ZimController.rescanLibrary
- "Rescan Library" button on the Content Manager page (shown when Kiwix
  is installed) with a success toast reporting books found

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:04:53 -07:00
jakeaturner e7527c4bd3
chore(deps): bump axios to 1.17.0 in admin 2026-06-06 17:59:35 +00:00
teccin 5d21f90aa5 Fix a few typos and punctuation in README.md 2026-06-06 10:55:20 -07:00
cosmistack-bot 17186ad8ed docs(release): finalize v1.32.1 release notes [skip ci] 2026-06-06 10:55:20 -07:00
cosmistack-bot 573e445a49 chore(release): 1.32.1 [skip ci] 2026-06-06 10:55:20 -07:00
jakeaturner 352d72819a docs: update release notes 2026-06-06 10:55:20 -07:00
jakeaturner 7bef5d394f chore(deps): bump various deps 2026-06-06 10:55:20 -07:00
chriscrosstalk d294f9df67 perf(KB): swap Qdrant full-scroll for facet on source enumeration (#928)
The Stored Files modal and per-file warnings panel both enumerate
distinct 'source' values in the qdrant content collection. The
existing implementation scrolls every point in the collection, 100 per
page, just to learn the unique sources. On a fully-ingested NOMAD this
is brutally slow: NOMAD3's 3.1M-point collection takes 50+ seconds to
return its ~40 distinct sources, and the same scan runs twice per modal
open (getStoredFiles + computeFileWarnings each pay it independently).

Qdrant 1.10+ supports a facet aggregation that returns the distinct
values of a payload field with their counts in a single call.
@qdrant/js-client-rest@1.16.2 (already pinned) exposes this as
`client.facet()`. Swap the three scroll loops:

- RagService.getStoredFiles
- RagService.computeFileWarnings
- RagService.\_scanAndSync (the source-set used for skip-already-embedded
  decisions)

As a bonus, computeFileWarnings's per-source chunk count comes back
inline from the facet, so the secondary scroll that was incrementing a
counter is also gone.

`exact: true` everywhere because the counts feed Warning A's
zero_chunks decision and could mis-fire near thresholds otherwise. New
RagService.FACET_SOURCE_LIMIT = 10000 caps the facet response; real
NOMADs run ~100 sources max, this is comfortable headroom.

Expected impact on NOMAD3 (3M points, 40 sources): ~50s -> ~100ms per
endpoint. On an empty/fresh KB: no measurable change either way.
2026-06-06 10:55:20 -07:00
chriscrosstalk b6932fda60 fix(KB): cursor on Always/Manual ingest policy buttons (#927)
Tailwind v4 dropped the default `button { cursor: pointer }` preflight
rule. The Always/Manual toggle in the KB modal uses inline <button>
elements (not the StyledButton wrapper that sets cursor-pointer
explicitly), so hovering them shows the default arrow cursor instead of
a pointer like every other clickable in the modal. Reported on NOMAD1
during v1.32.0 dogfooding.

Add cursor-pointer when not pending; keep cursor-not-allowed when the
mutation is in flight.
2026-06-06 10:55:20 -07:00
chriscrosstalk f046725531 fix(logging): also write production logs to stdout for docker visibility (#870)
In production, the logger was configured with a single file target writing to
`/app/storage/logs/admin.log`. Because the production pino transport had no
stdout target, `docker logs nomad_admin` only saw startup banner lines from
non-pino sources — every `logger.info`/`logger.debug` call from controllers,
services, and providers was effectively invisible from outside the container.

That's been silently masking diagnostics for anyone trying to debug a running
NOMAD without exec-ing into the admin container and tailing the log file.
RAG retrieval scores, query rewrites, container preflight decisions, version
check results — all of it was there in `admin.log` but absent from any
external observation point (docker logs, container log aggregators, etc.).

This adds a second production target writing JSON to stdout (fd 1) via the
same pino/file transport. Effect: `docker logs nomad_admin` now shows the
full runtime telemetry, the persisted log file is unchanged (so Debug Info
bundle export keeps working), and external log aggregators that scrape
container stdout now have something to scrape.

Verified on NOMAD8 (v1.32.0-rc.3): post-patch `docker logs --tail 15
nomad_admin` shows the structured `{"level":30,...,"msg":"[VersionCheckProvider]
Checking for stale updateAvailable..."}` lines that previously only existed
in admin.log. File destination still receives writes (size delta confirmed
after an `/api/system/info` hit).

This is the unblock for the AI Quality eval work — without log visibility,
every diagnostic conversation about RAG retrieval and query rewriting is
guesswork.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 10:55:20 -07:00
jakeaturner 65f96ee1e1 fix(rag): improve context-reliance hedging and use heading metadata at query time
Users often saw the assistant disclaim ("Sorry, I couldn't find specific
context regarding X, but here's a general answer...") even when material
that directly answered the query was embedded. Two compounding causes,
both on the read/generation side:

1. The rag_context system prompt explicitly authorized the hedge: it told
   the model to fall back to general knowledge and "acknowledge the
   limitations," and mandated "According to the information available..."
   citations that pushed small models into meta-commentary preambles.
   Rewrote it to treat the retrieved context as the primary, authoritative
   source, lead with the answer, fall back to general knowledge *silently*,
   and never emit "couldn't find specific context" phrasing, while still allowing
   the model itself to make some "sanity-check" decisions if the retrieved context
   seems wildly incorrect or unrelated to the user's query.

2. The injected context labeled each block with a raw relevance score
   (e.g. "Relevance: 42.3%"). nomic-embed cosine scores for genuinely
   relevant passages sit ~0.4-0.6, so the number primed the model to
   distrust correct context. Dropped the model-visible score (it stays in
   the logs) and replaced it with a neutral source-title label.

Also added a conservative, score-scaled heading boost in rerankResults:
when query keywords match a chunk's section/article title (ZIM metadata we
already fetch), nudge its rank. Same diminishing-returns shape as the
existing boosts and gated behind the existing semantic-quality threshold,
so it can't promote a weak match.

Scope note: this removes the visible hedge and improves ranking of
already-retrieved chunks. It does NOT fix retrieval recall — diluted
1500-token chunks that never reach dense search's top-k are unaffected.
That's a follow-up (smaller chunks + BM25/RRF hybrid).
2026-06-03 21:58:28 -07:00
Jake Turner 6fb070832d
docs: update release notes 2026-05-20 19:36:51 +00:00
55 changed files with 224 additions and 1913 deletions

View File

@ -4,7 +4,6 @@ FROM node:22-slim AS base
RUN apt-get update && apt-get install -y \
bash \
curl \
openssl \
graphicsmagick \
libvips-dev \
build-essential \

View File

@ -48,8 +48,6 @@ N.O.M.A.D. is a management UI ("Command Center") and API that orchestrates a col
- **Data Tools** — encryption, encoding, and analysis via [CyberChef](https://gchq.github.io/CyberChef/)
- **Notes** — local note-taking via [FlatNotes](https://github.com/dullage/flatnotes)
- **System Benchmark** — hardware scoring with a [community leaderboard](https://benchmark.projectnomad.us)
- **Supply Depot** — a one-click app catalog (PDF tools, file browser, e-book library, password manager, and more) plus the ability to run your own custom Docker containers
- **Automatic Updates** — opt-in, hands-off updates for the core software, installed apps, and offline content, on a schedule you control
- **Easy Setup Wizard** — guided first-time configuration with curated content collections
N.O.M.A.D. also includes built-in tools like a Wikipedia content selector, ZIM library manager, and content explorer.
@ -61,11 +59,10 @@ N.O.M.A.D. also includes built-in tools like a Wikipedia content selector, ZIM l
| Information Library | Kiwix | Offline Wikipedia, medical references, survival guides, ebooks |
| AI Assistant | Ollama + Qdrant | Built-in chat with document upload and semantic search |
| Education Platform | Kolibri | Khan Academy courses, progress tracking, multi-user support |
| Offline Maps | ProtoMaps | Downloadable regional maps for offline viewing and search |
| Offline Maps | ProtoMaps | Downloadable regional maps with search and navigation |
| Data Tools | CyberChef | Encryption, encoding, hashing, and data analysis |
| Notes | FlatNotes | Local note-taking with markdown support |
| System Benchmark | Built-in | Hardware scoring, Builder Tags, and community leaderboard |
| Supply Depot | Built-in | One-click app catalog + bring-your-own custom Docker containers |
## Device Requirements
While many similar offline survival computers are designed to be run on bare-minimum, lightweight hardware, Project N.O.M.A.D. is quite the opposite. To install and run the
@ -108,9 +105,7 @@ For answers to common questions about Project N.O.M.A.D., please see our [FAQ](F
## 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.
To test internet connectivity, N.O.M.A.D. first attempts to make a request to Cloudflare's utility endpoint, `https://1.1.1.1/cdn-cgi/trace`. If that endpoint is unreachable (for example, because your network blocks `1.1.1.1`), it falls back to other endpoints the application already contacts (the GitHub API and the Project N.O.M.A.D. API) and considers the connection online if any of them respond.
You can override the endpoint used for this check in two ways. The connectivity test URL can be configured from the UI under **Settings → Advanced** (stored locally on your instance), or you can set the `INTERNET_STATUS_TEST_URL` environment variable. When set, the environment variable always takes precedence over the UI-configured value. If neither is set, the built-in defaults above are used.
To test internet connectivity, N.O.M.A.D. first attempts to make a request to Cloudflare's utility endpoint, `https://1.1.1.1/cdn-cgi/trace`. If that endpoint is unreachable (for example, because your network blocks `1.1.1.1`), it falls back to other endpoints the application already contacts (the GitHub API and the Project N.O.M.A.D. API) and considers the connection online if any of them respond. You can override the endpoint used for this check with the `INTERNET_STATUS_TEST_URL` environment variable.
## 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 its resources), you can block/open ports to control which services are exposed.

View File

@ -7,7 +7,7 @@ import app from '@adonisjs/core/services/app'
import { randomBytes } from 'node:crypto'
import { sanitizeFilename } from '../utils/fs.js'
import { basename } from 'node:path'
import { deleteFileSchema, embedFileSchema, estimateBatchSchema, fileSourceSchema, getJobStatusSchema } from '#validators/rag'
import { deleteFileSchema, embedFileSchema, estimateBatchSchema, getJobStatusSchema } from '#validators/rag'
import logger from '@adonisjs/core/services/logger'
@inject()
@ -110,14 +110,6 @@ export default class RagController {
})
}
public async cancelAllJobs({ response }: HttpContext) {
const result = await EmbedFileJob.cancelAllJobs()
return response.status(200).json({
message: `Cancelled ${result.cancelled} job${result.cancelled !== 1 ? 's' : ''}${result.filesDeleted > 0 ? `, deleted ${result.filesDeleted} file${result.filesDeleted !== 1 ? 's' : ''}` : ''}.`,
...result,
})
}
public async policyPromptState({ response }: HttpContext) {
const result = await this.ragService.getPolicyPromptState()
return response.status(200).json(result)
@ -170,23 +162,4 @@ export default class RagController {
const result = await KbRatioRegistry.estimateBatch(normalized)
return response.status(200).json(result)
}
public async getFileContent({ request, response }: HttpContext) {
const { source } = await request.validateUsing(fileSourceSchema)
const result = await this.ragService.readFileContent(source)
if (!result) {
return response.status(404).json({ error: 'File not found or not viewable' })
}
return response.status(200).json(result)
}
public async downloadFile({ request, response }: HttpContext) {
const { source } = await request.validateUsing(fileSourceSchema)
const filePath = await this.ragService.resolveDownloadPath(source)
if (!filePath) {
return response.status(404).json({ error: 'File not found' })
}
const fileName = filePath.split(/[/\\]/).at(-1) ?? 'download'
return response.attachment(filePath, fileName)
}
}

View File

@ -6,7 +6,6 @@ import { SystemService } from '#services/system_service'
import { getSettingSchema, updateSettingSchema, validateSettingValue } from '#validators/settings'
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import env from '#start/env'
@inject()
export default class SettingsController {
@ -111,19 +110,6 @@ export default class SettingsController {
})
}
async advanced({ inertia }: HttpContext) {
// When the env var is set it always takes precedence over the stored value,
// so surface that to the UI to disable the field and explain the override.
const envOverride = Boolean(env.get('INTERNET_STATUS_TEST_URL')?.trim())
const internetStatusTestUrl = await KVStore.getValue('system.internetStatusTestUrl')
return inertia.render('settings/advanced', {
advanced: {
internetStatusTestUrl: internetStatusTestUrl ?? '',
internetStatusTestUrlEnvOverride: envOverride,
},
})
}
async getSetting({ request, response }: HttpContext) {
const { key } = await getSettingSchema.validate({ key: request.qs().key });
const value = await KVStore.getValue(key);

View File

@ -18,7 +18,6 @@ import {
preflightValidator,
serviceLogsValidator,
subscribeToReleaseNotesValidator,
uninstallServiceValidator,
updateCustomAppValidator,
updateServiceValidator,
setServiceAutoUpdateValidator,
@ -468,37 +467,6 @@ export default class SystemController {
return response.send({ success: true, message: `Custom app ${payload.service_name} deleted` })
}
/** Uninstall a curated catalog app: stop + remove its container (optionally its image) and
* return the card to the available catalog. App data under the storage path stays on disk,
* so a later reinstall picks it back up. Custom apps are removed via deleteCustomApp instead,
* which also drops their DB record. */
async uninstallService({ request, response }: HttpContext) {
const payload = await request.validateUsing(uninstallServiceValidator)
const service = await Service.query().where('service_name', payload.service_name).first()
if (!service) {
return response.status(404).send({ error: `Service ${payload.service_name} not found` })
}
if (service.is_custom) {
return response.status(403).send({ error: 'Custom apps are removed via delete.' })
}
if (service.is_dependency_service) {
return response.status(403).send({ error: 'Dependency services cannot be uninstalled directly.' })
}
if (!service.installed) {
return response.status(409).send({ error: `Service ${payload.service_name} is not installed` })
}
const result = await this.dockerService.uninstallService(
payload.service_name,
payload.remove_image ?? false
)
if (!result.success) {
return response.status(500).send({ success: false, message: result.message })
}
return response.send({ success: true, message: result.message })
}
/** Set or clear an app's custom launch URL (works for curated and custom apps). Purely a
* metadata change no container is touched. An empty/invalid value clears the override, after
* which the default host + port link is used again. */

View File

@ -8,12 +8,7 @@ import {
} from '#validators/common'
import { addCustomLibraryValidator, browseLibraryValidator, idParamValidator, listRemoteZimValidator } from '#validators/zim'
import { inject } from '@adonisjs/core'
import logger from '@adonisjs/core/services/logger'
import type { HttpContext } from '@adonisjs/core/http'
import { createWriteStream } from 'fs'
import { rename } from 'fs/promises'
import { join, resolve, sep } from 'path'
import { ZIM_STORAGE_PATH, ensureDirectoryExists, sanitizeFilename } from '../utils/fs.js'
@inject()
export default class ZimController {
@ -88,87 +83,6 @@ export default class ZimController {
}
}
async upload({ request, response }: HttpContext) {
let filename: string | null = null
let tmpPath: string | null = null
let uploadError: string | null = null
try {
const basePath = resolve(join(process.cwd(), ZIM_STORAGE_PATH))
await ensureDirectoryExists(basePath)
request.multipart.onFile('*', {}, async (part) => {
const clientName = part.filename || ''
if (!clientName.toLowerCase().endsWith('.zim')) {
part.resume()
uploadError = 'INVALID_TYPE'
return
}
const sanitized = sanitizeFilename(clientName)
const finalPath = resolve(join(basePath, sanitized))
if (!finalPath.startsWith(basePath + sep)) {
part.resume()
uploadError = 'INVALID_FILENAME'
return
}
const { access } = await import('fs/promises')
const exists = await access(finalPath).then(() => true).catch(() => false)
if (exists) {
part.resume()
uploadError = 'DUPLICATE_FILENAME'
return
}
filename = sanitized
tmpPath = finalPath + '.tmp'
const ws = createWriteStream(tmpPath)
await new Promise<void>((res, rej) => {
ws.on('error', rej)
ws.on('finish', res)
part.on('error', rej)
part.pipe(ws)
})
await rename(tmpPath, finalPath)
tmpPath = null
})
await request.multipart.process()
if (uploadError === 'INVALID_TYPE') {
return response.status(422).send({ message: 'Only .zim files are accepted' })
}
if (uploadError === 'INVALID_FILENAME') {
return response.status(422).send({ message: 'Invalid filename' })
}
if (uploadError === 'DUPLICATE_FILENAME') {
return response.status(409).send({ message: 'A ZIM file with that name already exists' })
}
if (!filename) {
return response.status(400).send({ message: 'No file received' })
}
const { added } = await this.zimService.registerLocalUpload(filename)
return response.status(201).send({
message: 'ZIM file uploaded and registered successfully',
filename,
added,
})
} catch (error) {
logger.error('[ZimController] Upload failed:', error)
if (tmpPath) {
const { unlink } = await import('fs/promises')
await unlink(tmpPath).catch(() => {})
}
return response.status(500).send({ message: 'Upload failed' })
}
}
// Wikipedia selector endpoints
async getWikipediaState({}: HttpContext) {

View File

@ -164,22 +164,6 @@ export class EmbedFileJob {
await new Promise((resolve) => setTimeout(resolve, EmbedFileJob.CPU_BATCH_DELAY_MS))
}
// Bail before re-populating the queue if this job was cancelled mid-batch.
// cancelAllJobs() obliterates the queue (including this active job), but a
// worker already inside handle() would otherwise dispatch its continuation
// afterwards and silently revive a cancelled ZIM ingestion. If our own job
// key is gone, the cancel happened — skip the dispatch. Mirrors the
// "tolerate external removal" handling in safeUpdateProgress above.
const stillQueued = await QueueService.getInstance()
.getQueue(EmbedFileJob.queue)
.getJob(job.id!)
if (!stillQueued) {
logger.info(
`[EmbedFileJob] Job ${fileName} was cancelled; skipping continuation dispatch`
)
return { success: false, cancelled: true, fileName, filePath }
}
// Dispatch next batch (not final yet). Carry forward the running
// chunk count so the final batch can persist an accurate total (#933).
const chunksSoFarNext = (job.data.chunksSoFar || 0) + (result.chunks || 0)
@ -459,46 +443,6 @@ export class EmbedFileJob {
return { cleaned, filesDeleted }
}
/** Unconditionally clear every embedding job regardless of state.
*
* cleanupFailedJobs only removes jobs explicitly tagged status === 'failed',
* which leaves stuck jobs (waiting / active / delayed / paused that never
* reached 'failed') unreachable from the UI the operator's only recourse was
* flushing Redis by hand. This wipes the whole queue, including a locked active
* job, via obliterate({ force: true }) (plain obliterate/job.remove throw on a
* locked job). It touches only Redis, so it is safe while Qdrant/Ollama are
* offline which is exactly when jobs pile up and wedge. */
static async cancelAllJobs(): Promise<{ cancelled: number; filesDeleted: number }> {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const jobs = await queue.getJobs(['waiting', 'active', 'delayed', 'paused', 'failed'])
let filesDeleted = 0
for (const job of jobs) {
const filePath = (job.data as EmbedFileJobParams).filePath
// Same guard as cleanupFailedJobs: only delete user uploads, never ZIM
// library files or Nomad docs that live outside the uploads path.
if (filePath && filePath.includes(RagService.UPLOADS_STORAGE_PATH)) {
try {
await fs.unlink(filePath)
filesDeleted++
} catch {
// File may already be deleted — that's fine
}
}
}
const cancelled = jobs.length
// force: true removes the locked/active job too. An in-flight worker may keep
// running its current batch in memory; the self-exists guard in handle()
// prevents it from dispatching a continuation back into the cleared queue.
await queue.obliterate({ force: true })
logger.info(`[EmbedFileJob] Cancelled ${cancelled} jobs, deleted ${filesDeleted} files`)
return { cancelled, filesDeleted }
}
static async getStatus(filePath: string): Promise<{
exists: boolean
status?: string

View File

@ -85,16 +85,6 @@ export default class Service extends BaseModel {
@column()
declare category: string | null
// When true the service is sunset: hidden from the install catalog unless it is already
// installed (see SystemService.getServices). Lets a deprecated app stay manageable for users who
// still run it while keeping new users from installing it.
@column({
serialize(value) {
return Boolean(value)
},
})
declare is_deprecated: boolean
@column()
declare source_repo: string | null

View File

@ -12,7 +12,6 @@ import {
BOOKS_STORAGE_PATH,
CALIBRE_EMPTY_LIBRARY_ASSET_PATH,
VAULTWARDEN_STORAGE_PATH,
MESHCORE_WEB_STORAGE_PATH,
MEDIA_STORAGE_PATH,
JELLYFIN_MEDIA_SUBFOLDERS,
} from '../utils/fs.js'
@ -20,7 +19,7 @@ import { KiwixLibraryService } from './kiwix_library_service.js'
import { SERVICE_NAMES } from '../../constants/service_names.js'
import { exec } from 'child_process'
import { promisify } from 'util'
import { readFile, mkdir, copyFile, chown, chmod, access, writeFile } from 'node:fs/promises'
import { readFile, mkdir, copyFile, chown, chmod, access } from 'node:fs/promises'
import KVStore from '#models/kv_store'
import { BROADCAST_CHANNELS } from '../../constants/broadcast.js'
import { KIWIX_LIBRARY_CMD } from '../../constants/kiwix.js'
@ -698,15 +697,6 @@ export class DockerService {
)
}
if (service.service_name === SERVICE_NAMES.MESHCORE_WEB) {
await this._runPreinstallActions__MeshCoreWeb()
this._broadcast(
service.service_name,
'preinstall-complete',
`Pre-install actions for MeshCore Web completed successfully.`
)
}
// GPU-aware configuration for Ollama
let finalImage = service.container_image
let gpuHostConfig = containerConfig?.HostConfig || {}
@ -1040,58 +1030,18 @@ export class DockerService {
}
}
/**
* Ensure a self-signed TLS cert (cert.pem + key.pem) exists in `certDir`, generating one if not.
* Used by apps that need a secure (HTTPS) context but run on a LAN appliance with no public DNS to
* get a trusted cert for. Idempotent: an existing pair is left untouched, so the cert is stable
* across reinstalls (no fresh browser warning each time) and a cert an admin swapped in by hand is
* never clobbered. The private key is locked to 0600; the cert stays world-readable.
*/
private async _ensureSelfSignedCert(
certDir: string,
commonName: string
): Promise<{ certPath: string; keyPath: string }> {
const certPath = join(certDir, 'cert.pem')
const keyPath = join(certDir, 'key.pem')
await mkdir(certDir, { recursive: true })
const alreadyHasCert = await Promise.all([
access(certPath)
.then(() => true)
.catch(() => false),
access(keyPath)
.then(() => true)
.catch(() => false),
]).then(([c, k]) => c && k)
if (alreadyHasCert) return { certPath, keyPath }
// 10-year self-signed cert. CN/SAN are cosmetic for a self-signed cert (the browser warns
// regardless), but a SAN keeps it structurally valid for clients that require one.
const execAsync = promisify(exec)
await execAsync(
`openssl req -x509 -newkey rsa:2048 -nodes ` +
`-keyout "${keyPath}" -out "${certPath}" -days 3650 ` +
`-subj "/CN=${commonName}" ` +
`-addext "subjectAltName=DNS:nomad,DNS:localhost"`
)
await chmod(keyPath, 0o600)
await chmod(certPath, 0o644)
return { certPath, keyPath }
}
/**
* Vaultwarden's web vault refuses to run without a secure context over plain HTTP it shows
* "You need to enable HTTPS!" and you can't register or unlock. We give it a self-signed TLS
* cert in its /data volume so it serves HTTPS out of the box (paired with the ROCKET_TLS env and
* the `https:8480` ui_location set in the seeder). Users click through a one-time browser warning;
* after that the vault is fully functional offline.
* after that the vault is fully functional offline. Self-signed is the only option on a LAN
* appliance with no public DNS to get a trusted cert for.
*/
private async _runPreinstallActions__Vaultwarden(): Promise<void> {
const dataDir = join(process.cwd(), VAULTWARDEN_STORAGE_PATH)
const certPath = join(dataDir, 'cert.pem')
const keyPath = join(dataDir, 'key.pem')
this._broadcast(
SERVICE_NAMES.VAULTWARDEN,
@ -1100,11 +1050,48 @@ export class DockerService {
)
try {
await this._ensureSelfSignedCert(dataDir, 'Project NOMAD Vaultwarden')
await mkdir(dataDir, { recursive: true })
// Don't regenerate if a cert is already present — keeps the same cert across reinstalls so
// users don't get a fresh browser warning every time, and never clobbers a cert an admin
// may have swapped in themselves.
const alreadyHasCert = await Promise.all([
access(certPath)
.then(() => true)
.catch(() => false),
access(keyPath)
.then(() => true)
.catch(() => false),
]).then(([c, k]) => c && k)
if (alreadyHasCert) {
this._broadcast(
SERVICE_NAMES.VAULTWARDEN,
'preinstall',
`Existing Vaultwarden TLS certificate found — leaving it as-is.`
)
return
}
// 10-year self-signed cert. CN/SAN are cosmetic for a self-signed cert (the browser warns
// regardless), but a SAN keeps it structurally valid for clients that require one.
const execAsync = promisify(exec)
await execAsync(
`openssl req -x509 -newkey rsa:2048 -nodes ` +
`-keyout "${keyPath}" -out "${certPath}" -days 3650 ` +
`-subj "/CN=Project NOMAD Vaultwarden" ` +
`-addext "subjectAltName=DNS:nomad,DNS:localhost"`
)
// Lock the private key down; the cert can stay world-readable. Vaultwarden runs as root and
// reads both from /data, so no chown is needed (admin writes them as root too).
await chmod(keyPath, 0o600)
await chmod(certPath, 0o644)
this._broadcast(
SERVICE_NAMES.VAULTWARDEN,
'preinstall',
`Vaultwarden HTTPS certificate is ready.`
`Generated a self-signed HTTPS certificate for Vaultwarden.`
)
} catch (error: any) {
this._broadcast(
@ -1116,63 +1103,6 @@ export class DockerService {
}
}
/**
* The MeshCore web client (aXistem's prebuilt image) is stock nginx serving a static Flutter build
* over plain HTTP. The client reaches a radio over Web Bluetooth / Web Serial, which browsers only
* permit from a secure (HTTPS) context so over plain HTTP the app loads but can't connect to a
* thing. We generate a self-signed cert and a small SSL nginx config here; the seeder bind-mounts
* both into the container (the config over the image's default.conf) so it serves the same static
* files over HTTPS instead. Same one-time-browser-warning approach as Vaultwarden.
*/
private async _runPreinstallActions__MeshCoreWeb(): Promise<void> {
const appDir = join(process.cwd(), MESHCORE_WEB_STORAGE_PATH)
const certDir = join(appDir, 'certs')
const nginxConfPath = join(appDir, 'nginx-ssl.conf')
this._broadcast(
SERVICE_NAMES.MESHCORE_WEB,
'preinstall',
`Running pre-install actions for MeshCore Web...`
)
try {
await this._ensureSelfSignedCert(certDir, 'Project NOMAD MeshCore Web')
// SSL server block bind-mounted over the image's default.conf. Serves the Flutter build that
// already lives at /usr/share/nginx/html in the image, over HTTPS only, with the SPA fallback
// that single-page apps need. Cert paths match the /certs bind mount set in the seeder.
const nginxConf =
[
'server {',
' listen 443 ssl;',
' server_name _;',
' ssl_certificate /certs/cert.pem;',
' ssl_certificate_key /certs/key.pem;',
' root /usr/share/nginx/html;',
' index index.html;',
' location / {',
' try_files $uri $uri/ /index.html;',
' }',
'}',
].join('\n') + '\n'
await writeFile(nginxConfPath, nginxConf)
await chmod(nginxConfPath, 0o644)
this._broadcast(
SERVICE_NAMES.MESHCORE_WEB,
'preinstall',
`MeshCore Web HTTPS certificate and config are ready.`
)
} catch (error: any) {
this._broadcast(
SERVICE_NAMES.MESHCORE_WEB,
'preinstall-error',
`Failed to prepare MeshCore Web: ${error.message}`
)
throw new Error(`Pre-install action failed: ${error.message}`)
}
}
/**
* Jellyfin works best when each library points at its own subfolder. Pointing one library at the
* whole media root and another at a subfolder inside it makes Jellyfin report a "duplicate path"
@ -1937,34 +1867,6 @@ export class DockerService {
}
}
/**
* Uninstall a curated catalog app: stop and remove its container (and, optionally, its image),
* then mark the record not-installed so the card returns to the available catalog. Host
* bind-mount data is deliberately left on disk a later reinstall picks it back up. Contrast
* with forceReinstall, which clears volumes and immediately recreates the container.
*/
async uninstallService(
serviceName: string,
removeImage = false
): Promise<{ success: boolean; message: string }> {
const service = await Service.query().where('service_name', serviceName).first()
if (!service || !service.installed) {
return { success: false, message: `Service ${serviceName} not found or not installed` }
}
// Container/image removal is shared with custom-app deletion — the lookup is by container
// name, which is the service_name for curated and custom apps alike.
const removal = await this.removeCustomAppContainer(serviceName, removeImage)
if (!removal.success) return removal
service.installed = false
service.installation_status = 'idle'
await service.save()
this.invalidateServicesStatusCache()
return { success: true, message: `Service ${serviceName} uninstalled` }
}
/** Find a container by its managed service name (`/serviceName`), or null. */
private async _findContainerByName(serviceName: string) {
const containers = await this.docker.listContainers({ all: true })

View File

@ -14,10 +14,9 @@ export class DocsService {
'use-cases': 3,
'supply-depot-apps': 4,
'community-add-ons': 5,
'updates': 6,
'faq': 7,
'about': 8,
'release-notes': 9,
'faq': 6,
'about': 7,
'release-notes': 8,
}
async getDocs() {

View File

@ -1,11 +1,11 @@
import { Queue } from 'bullmq'
import queueConfig from '#config/queue'
// Process-wide singleton. Instantiating a fresh QueueService per dispatch /
// status lookup leaks connections, and under sustained job churn (e.g.
// multi-batch ZIM ingestion enqueueing a continuation every few seconds) it
// saturates Redis's maxclients within hours. All queues additionally reuse the
// single shared ioredis instance exported from #config/queue (#885).
// Process-wide singleton. Each `Queue` opens two ioredis connections (one for
// commands, one blocking). Instantiating a fresh QueueService per dispatch /
// status lookup leaks both, and under sustained job churn (e.g. multi-batch ZIM
// ingestion enqueueing a continuation every few seconds) it saturates Redis's
// maxclients within hours.
export class QueueService {
private queues: Map<string, Queue> = new Map()

View File

@ -1140,90 +1140,20 @@ export class RagService {
)
}
const uploadsAbsPath = resolve(join(process.cwd(), RagService.UPLOADS_STORAGE_PATH))
return await Promise.all(
Array.from(sources).map(async (source) => {
const row = stateByPath.get(source)
const fileName = source.split(/[/\\]/).at(-1) ?? source
const isUserUpload = resolve(source).startsWith(uploadsAbsPath + sep)
const stats = await getFileStatsIfExists(source)
return {
source,
state: row?.state ?? null,
chunksEmbedded: row?.chunks_embedded ?? 0,
fileName,
size: stats?.size ?? null,
uploadedAt: stats?.modifiedTime.toISOString() ?? null,
isUserUpload,
}
})
)
return Array.from(sources).map((source) => {
const row = stateByPath.get(source)
return {
source,
state: row?.state ?? null,
chunksEmbedded: row?.chunks_embedded ?? 0,
}
})
} catch (error) {
logger.error('Error retrieving stored files:', error)
return []
}
}
/**
* Resolve a stored-file `source` to an absolute disk path, but only if the
* path lives under the uploads directory. Mirrors the docs_service traversal
* guard: resolve, then require the resolved path to be strictly inside the
* base + path separator (so siblings of `kb_uploads` can't slip through).
* Returns null for anything else ZIMs, admin docs, README, or paths
* outside the app entirely. The viewer/download endpoints lean on this so
* they don't need to repeat the check.
*/
private resolveUploadPath(source: string): string | null {
const uploadsAbsPath = resolve(join(process.cwd(), RagService.UPLOADS_STORAGE_PATH))
const resolved = resolve(source)
if (!resolved.startsWith(uploadsAbsPath + sep)) return null
return resolved
}
private static readonly VIEWABLE_TEXT_EXTENSIONS: ReadonlySet<string> = new Set([
'md', 'txt', 'csv', 'json', 'yaml', 'yml', 'toml', 'xml', 'html',
])
/**
* Read the text content of a user-uploaded file for in-browser viewing.
* Returns null when the source is outside uploads, missing, or not a
* recognized text extension. The extension allowlist is intentionally narrow
* PDFs/EPUBs/ZIMs round-trip through Download, not the viewer.
*/
public async readFileContent(
source: string
): Promise<{ content: string; extension: string; fileName: string } | null> {
const resolved = this.resolveUploadPath(source)
if (!resolved) return null
const extension = resolved.split('.').at(-1)?.toLowerCase() ?? ''
if (!RagService.VIEWABLE_TEXT_EXTENSIONS.has(extension)) return null
const stats = await getFileStatsIfExists(resolved)
if (!stats) return null
try {
const { readFile } = await import('node:fs/promises')
const content = await readFile(resolved, 'utf-8')
const fileName = resolved.split(/[/\\]/).at(-1) ?? resolved
return { content, extension, fileName }
} catch (error) {
logger.warn({ err: error, source }, '[RagService.readFileContent] read failed')
return null
}
}
/**
* Resolve a download-target path for a stored upload. Returns null if the
* source isn't an upload or the file is missing on disk.
*/
public async resolveDownloadPath(source: string): Promise<string | null> {
const resolved = this.resolveUploadPath(source)
if (!resolved) return null
const stats = await getFileStatsIfExists(resolved)
return stats ? resolved : null
}
/**
* Compute whether the first-chat JIT prompt should fire and surface the file
* count the banner uses in its copy ("Index your N existing files?"). The

View File

@ -49,37 +49,31 @@ export class SystemService {
const MAX_ATTEMPTS = 3
let testUrls = DEFAULT_TEST_URLS
const customTestUrl = env.get('INTERNET_STATUS_TEST_URL')?.trim()
// Resolve the test endpoint in priority order: the INTERNET_STATUS_TEST_URL
// env var always wins (legacy override for operators who intentionally point
// connectivity checks at a specific endpoint), then the UI-configurable value
// stored in KVStore, and finally the built-in defaults.
const envTestUrl = env.get('INTERNET_STATUS_TEST_URL')?.trim()
const kvTestUrl = (await KVStore.getValue('system.internetStatusTestUrl'))?.trim()
const customTestUrl = envTestUrl || kvTestUrl
// If a custom test URL is provided and valid, use it exclusively.
// If a custom test URL is provided and valid, use it exclusively. This
// preserves the existing override behavior for operators who intentionally
// point connectivity checks at a specific endpoint.
if (customTestUrl && customTestUrl !== '') {
try {
new URL(customTestUrl)
testUrls = [customTestUrl]
} catch (error) {
logger.warn(
`Invalid internet status test URL: ${customTestUrl}. Falling back to default URLs.`
`Invalid INTERNET_STATUS_TEST_URL: ${customTestUrl}. Falling back to default URLs.`
)
}
}
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
// Probe all test endpoints in parallel and resolve as soon as the first one
// Probe all endpoints in parallel and resolve as soon as the first one
// responds. Any HTTP response (including non-2xx) means we reached the
// internet, so accept all status codes rather than requiring a strict 200.
await Promise.any(
testUrls.map((testUrl) => {
logger.debug(`[SystemService] Checking internet connectivity via: ${testUrl}`)
return axios.get(testUrl, { timeout: 5000, validateStatus: () => true })
})
testUrls.map((testUrl) =>
axios.get(testUrl, { timeout: 5000, validateStatus: () => true })
)
)
return true
} catch (error) {
@ -339,15 +333,9 @@ export class SystemService {
'auto_update_enabled',
'is_custom',
'is_user_modified',
'is_deprecated',
'category'
)
.where('is_dependency_service', false)
// Deprecated/sunset apps stay visible only while still installed, so the user can manage and
// uninstall them — they never reappear in the install catalog once removed.
.where((q) => {
q.where('is_deprecated', false).orWhere('installed', true)
})
if (installedOnly) {
query.where('installed', true)
}
@ -379,7 +367,6 @@ export class SystemService {
auto_update_enabled: service.auto_update_enabled,
is_custom: service.is_custom,
is_user_modified: service.is_user_modified,
is_deprecated: service.is_deprecated,
category: service.category,
})
}

View File

@ -25,7 +25,6 @@ import vine from '@vinejs/vine'
import { wikipediaOptionsFileSchema } from '#validators/curated_collections'
import WikipediaSelection from '#models/wikipedia_selection'
import InstalledResource from '#models/installed_resource'
import CollectionManifest from '#models/collection_manifest'
import { RunDownloadJob } from '#jobs/run_download_job'
import { SERVICE_NAMES } from '../../constants/service_names.js'
import { CollectionManifestService } from './collection_manifest_service.js'
@ -470,97 +469,6 @@ export class ZimService {
return { before, after, added: Math.max(0, after - before) }
}
async registerLocalUpload(filename: string): Promise<{ added: number }> {
let added = 0
try {
const result = await this.rescanLibrary()
added = result.added
} catch (err) {
logger.error('[ZimService] Failed to rebuild kiwix library after local upload:', err)
}
const parsed = CollectionManifestService.parseZimFilename(filename)
if (parsed) {
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
const stats = await getFileStatsIfExists(filepath)
try {
const { DateTime } = await import('luxon')
await InstalledResource.updateOrCreate(
{ resource_id: parsed.resource_id, resource_type: 'zim' },
{
version: parsed.version,
url: `local-upload://${filename}`,
file_path: filepath,
file_size_bytes: stats ? Number(stats.size) : null,
installed_at: DateTime.now(),
}
)
} catch (error) {
logger.error(`[ZimService] Failed to create InstalledResource for ${filename}:`, error)
}
}
// If the uploaded file matches a known Wikipedia option, mark it as installed
try {
const manifest = await CollectionManifest.find('wikipedia')
if (manifest) {
const spec = manifest.spec_data as { options: Array<{ id: string; url: string | null }> }
const matchedOption = spec.options.find(
(opt) => opt.url && opt.url.split('/').pop() === filename
)
if (matchedOption && matchedOption.url) {
const existing = await WikipediaSelection.query().first()
if (existing) {
existing.option_id = matchedOption.id
existing.url = matchedOption.url
existing.filename = filename
existing.status = 'installed'
await existing.save()
} else {
await WikipediaSelection.create({
option_id: matchedOption.id,
url: matchedOption.url,
filename,
status: 'installed',
})
}
logger.info(`[ZimService] Marked Wikipedia option '${matchedOption.id}' as installed from local upload`)
// Remove any other wikipedia_en_*.zim files, same as the download flow
const allFiles = await this.list()
const staleWikipediaFiles = allFiles.files.filter(
(f) => f.name.startsWith('wikipedia_en_') && f.name !== filename
)
for (const stale of staleWikipediaFiles) {
try {
await this.delete(stale.name)
logger.info(`[ZimService] Deleted stale Wikipedia file after upload: ${stale.name}`)
} catch (err) {
logger.warn(`[ZimService] Could not delete stale Wikipedia file: ${stale.name}`, err)
}
}
}
}
} catch (error) {
logger.error(`[ZimService] Failed to update WikipediaSelection for ${filename}:`, error)
}
const ollamaUrl = await this.dockerService.getServiceURL('nomad_ollama')
if (ollamaUrl) {
try {
const { EmbedFileJob } = await import('#jobs/embed_file_job')
await EmbedFileJob.dispatch({
fileName: filename,
filePath: join(process.cwd(), ZIM_STORAGE_PATH, filename),
})
} catch (error) {
logger.error(`[ZimService] EmbedFileJob dispatch failed after local upload:`, error)
}
}
return { added }
}
async delete(file: string): Promise<void> {
let fileName = file
if (!fileName.endsWith('.zim')) {
@ -597,21 +505,6 @@ export class ZimService {
.delete()
logger.info(`[ZimService] Deleted InstalledResource entry for: ${parsed.resource_id}`)
}
// If this file was the active Wikipedia selection, clear the selection
try {
const selection = await WikipediaSelection.query().first()
if (selection && selection.filename === fileName) {
selection.option_id = 'none'
selection.status = 'none'
selection.filename = null
selection.url = null
await selection.save()
logger.info(`[ZimService] Cleared WikipediaSelection after deleting ${fileName}`)
}
} catch (error) {
logger.error(`[ZimService] Failed to clear WikipediaSelection after deleting ${fileName}:`, error)
}
}
// Wikipedia selector methods

View File

@ -17,11 +17,6 @@ export const CALIBRE_EMPTY_LIBRARY_ASSET_PATH = 'assets/calibre/metadata.db'
// Vaultwarden's /data volume. A self-signed TLS cert is generated here on install so the
// web vault has the secure context (HTTPS) it requires — see _runPreinstallActions__Vaultwarden.
export const VAULTWARDEN_STORAGE_PATH = '/storage/vaultwarden'
// MeshCore Web's working dir. On install a self-signed cert (certs/) and an SSL nginx config
// (nginx-ssl.conf) are generated here, then bind-mounted into the container so the static client is
// served over HTTPS — required for its Web Bluetooth/Serial connections. See
// _runPreinstallActions__MeshCoreWeb.
export const MESHCORE_WEB_STORAGE_PATH = '/storage/meshcore-web'
export async function listDirectoryContents(path: string): Promise<FileEntry[]> {
const entries = await readdir(path, { withFileTypes: true })

View File

@ -19,12 +19,6 @@ export const embedFileSchema = vine.compile(
})
)
export const fileSourceSchema = vine.compile(
vine.object({
source: vine.string().minLength(1),
})
)
export const estimateBatchSchema = vine.compile(
vine.object({
files: vine

View File

@ -36,24 +36,6 @@ export function validateSettingValue(key: KVStoreKey, value: unknown): string |
}
return null
}
case 'system.internetStatusTestUrl': {
// Empty clears the setting (reverts to env var / built-in defaults).
if (value === '' || value === undefined || value === null) {
return null
}
if (typeof value !== 'string') {
return 'Test URL must be a string.'
}
try {
const url = new URL(value)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return 'Test URL must use http or https.'
}
} catch {
return 'Test URL must be a valid URL (e.g. "https://example.com").'
}
return null
}
case 'contentAutoUpdate.maxBytesPerWindow': {
// Per-window download budget in bytes. 0 = unlimited.
const num = Number(value)

View File

@ -131,14 +131,6 @@ export const deleteCustomAppValidator = vine.compile(
})
)
export const uninstallServiceValidator = vine.compile(
vine.object({
service_name: vine.string().trim(),
// When true, also remove the backing Docker image (best-effort).
remove_image: vine.boolean().optional(),
})
)
export const serviceLogsValidator = vine.compile(
vine.object({
tail: vine.number().min(1).max(2000).optional(),

View File

@ -41,7 +41,7 @@ const bodyParserConfig = defineConfig({
*/
autoProcess: true,
convertEmptyStringsToNull: true,
processManually: ['/api/zim/upload'],
processManually: [],
/**
* Maximum limit of data to parse including all files

View File

@ -1,42 +1,11 @@
import env from '#start/env'
import logger from '@adonisjs/core/services/logger'
import { Redis } from 'ioredis'
// BullMQ treats a plain `{host, port}` connection object as a recipe: every
// Queue / Worker instantiates its own ioredis client from it, and script
// commands executed against those clients can spawn further short-lived
// connections. Under sustained ZIM ingestion this leaked ~1 client/sec until
// Redis maxclients was exhausted (#885). Passing a single shared ioredis
// instance instead gives BullMQ a pool to reuse — Workers still duplicate it
// once for their blocking client, which is expected and bounded.
// `maxRetriesPerRequest: null` is mandatory for connections shared with BullMQ.
const sharedConnection = new Redis({
host: env.get('REDIS_HOST'),
port: env.get('REDIS_PORT') ?? 6379,
db: env.get('REDIS_DB') ?? 0,
maxRetriesPerRequest: null,
// Don't open the socket at module import time. Importing this file (during
// `node ace migration:run`, `db:seed`, `queue:work`, or HTTP boot) otherwise
// races Docker's network/DNS lifecycle: on a fresh `up` the `redis` name is
// not yet resolvable (EAI_AGAIN), and on a recreate the embedded DNS briefly
// serves the previous container's IP (ECONNREFUSED to the stale address).
// Lazy-connecting defers the first dial until BullMQ actually needs Redis —
// after the entrypoint has confirmed it is reachable — so each (re)connect
// re-resolves the current IP instead of hammering a stale one.
lazyConnect: true,
// Bounded, backing-off retry so a transient outage doesn't busy-loop.
retryStrategy: (times) => Math.min(times * 200, 2000),
})
// Without an `error` listener ioredis logs the raw "[ioredis] Unhandled error
// event" lines and, on some Node versions, an EventEmitter `error` with no
// listener can crash the process. Route them through the app logger instead.
sharedConnection.on('error', (err) => {
logger.error({ err }, 'Shared Redis connection error')
})
const queueConfig = {
connection: sharedConnection,
connection: {
host: env.get('REDIS_HOST'),
port: env.get('REDIS_PORT') ?? 6379,
db: env.get('REDIS_DB') ?? 0,
},
}
export default queueConfig

View File

@ -1,3 +1,3 @@
import { KVStoreKey } from "../types/kv_store.js";
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'ui.theme', 'system.earlyAccess', 'system.internetStatusTestUrl', 'ai.assistantCustomName', 'ai.remoteOllamaUrl', 'ai.ollamaFlashAttention', 'rag.defaultIngestPolicy', 'autoUpdate.enabled', 'autoUpdate.windowStart', 'autoUpdate.windowEnd', 'autoUpdate.cooloffHours', 'appAutoUpdate.enabled', 'contentAutoUpdate.enabled', 'contentAutoUpdate.windowStart', 'contentAutoUpdate.windowEnd', 'contentAutoUpdate.cooloffHours', 'contentAutoUpdate.maxBytesPerWindow'];
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'ui.theme', 'system.earlyAccess', 'ai.assistantCustomName', 'ai.remoteOllamaUrl', 'ai.ollamaFlashAttention', 'rag.defaultIngestPolicy', 'autoUpdate.enabled', 'autoUpdate.windowStart', 'autoUpdate.windowEnd', 'autoUpdate.cooloffHours', 'appAutoUpdate.enabled', 'contentAutoUpdate.enabled', 'contentAutoUpdate.windowStart', 'contentAutoUpdate.windowEnd', 'contentAutoUpdate.cooloffHours', 'contentAutoUpdate.maxBytesPerWindow'];

View File

@ -5,7 +5,6 @@ export const SERVICE_NAMES = {
CYBERCHEF: 'nomad_cyberchef',
FLATNOTES: 'nomad_flatnotes',
KOLIBRI: 'nomad_kolibri',
KOLIBRI_GEN2: 'nomad_kolibri_2',
// Supply Depot — curated catalog (ports 84008499)
STIRLING_PDF: 'nomad_stirling_pdf',
FILEBROWSER: 'nomad_filebrowser',
@ -14,7 +13,6 @@ export const SERVICE_NAMES = {
EXCALIDRAW: 'nomad_excalidraw',
MESHTASTIC_WEB: 'nomad_meshtastic_web',
MESHTASTICD: 'nomad_meshtasticd',
MESHCORE_WEB: 'nomad_meshcore_web',
HOMEBOX: 'nomad_homebox',
VAULTWARDEN: 'nomad_vaultwarden',
JELLYFIN: 'nomad_jellyfin',

View File

@ -17,9 +17,6 @@ export const SUPPLY_DEPOT_DOC_ANCHORS: Record<string, string> = {
[SERVICE_NAMES.VAULTWARDEN]: 'vaultwarden',
[SERVICE_NAMES.JELLYFIN]: 'jellyfin',
[SERVICE_NAMES.MESHTASTIC_WEB]: 'meshtastic-web',
[SERVICE_NAMES.KOLIBRI]: 'kolibri',
[SERVICE_NAMES.KOLIBRI_GEN2]: 'kolibri',
[SERVICE_NAMES.MESHCORE_WEB]: 'meshcore-web',
}
// Returns the in-app docs link for a service, or null if it has no documentation section.

View File

@ -1,43 +0,0 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
import { SERVICE_NAMES } from '../../constants/service_names.js'
export default class extends BaseSchema {
protected tableName = 'services'
async up() {
// Generic deprecation flag (reusable for future sunsets): a deprecated service is hidden from
// the install catalog unless it is already installed — see SystemService.getServices().
this.schema.alterTable(this.tableName, (table) => {
table.boolean('is_deprecated').notNullable().defaultTo(false)
})
// Sunset the legacy treehouses/kolibri:0.12.8 entry, replaced by the learningequality Gen 2
// entry seeded as `nomad_kolibri_2`. The seeder is additive + sync-existing and never deletes,
// so without this step every existing deployment keeps an orphaned `nomad_kolibri` row and can
// still install the dead 6-year-old image. Conditional handling keeps it data-safe:
this.defer(async (db) => {
// Never installed → just an orphaned catalog row; drop it outright.
await db
.from(this.tableName)
.where('service_name', SERVICE_NAMES.KOLIBRI)
.where('installed', false)
.delete()
// Currently installed → a running 0.12.8 container holds port 8300 + a bind mount. Keep the
// row (it's Nomad's only handle to open/stop/uninstall that container) but flag it deprecated
// so it shows a "Legacy" badge and drops out of the catalog once the user uninstalls it.
await db
.from(this.tableName)
.where('service_name', SERVICE_NAMES.KOLIBRI)
.where('installed', true)
.update({ is_deprecated: true })
})
}
async down() {
// Note: the legacy-row deletion in up() is a one-way data change and is not restored here.
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('is_deprecated')
})
}
}

View File

@ -14,7 +14,6 @@ type ServiceSeedRecord = Omit<
| 'update_checked_at'
| 'metadata'
| 'is_user_modified'
| 'is_deprecated'
| 'custom_url'
| 'auto_update_enabled'
| 'available_update_first_seen_at'
@ -92,7 +91,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.24.0',
container_image: 'ollama/ollama:0.18.1',
source_repo: 'https://github.com/ollama/ollama',
container_command: 'serve',
container_config: JSON.stringify({
@ -118,7 +117,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.24.0',
container_image: 'ghcr.io/gchq/cyberchef:10.22.1',
source_repo: 'https://github.com/gchq/CyberChef',
container_command: null,
container_config: JSON.stringify({
@ -164,38 +163,24 @@ export default class ServiceSeeder extends BaseSeeder {
depends_on: null,
},
{
// "Kolibri Gen 2" — the upstream-official learningequality image replacing the ~6-year-old
// community treehouses/kolibri:0.12.8. This is a distinct catalog entry (own service_name,
// volume, and ports), not an in-place upgrade: the new image uses a different repo, mounts at
// /kolibri instead of /root/.kolibri, and crosses 7 minor versions of Kolibri's own data
// schema. Existing 0.12.8 installs are sunset via the deprecate-legacy-kolibri migration and
// keep running on 8300 until uninstalled; content is re-imported into the fresh Gen 2 install.
service_name: SERVICE_NAMES.KOLIBRI_GEN2,
friendly_name: 'Education Platform (Gen 2)',
service_name: SERVICE_NAMES.KOLIBRI,
friendly_name: 'Education Platform',
powered_by: 'Kolibri',
display_order: 2,
description: 'Interactive learning platform with video courses and exercises',
icon: 'IconSchool',
container_image: 'learningequality/kolibri:0.19.4',
container_image: 'treehouses/kolibri:0.12.8',
source_repo: 'https://github.com/learningequality/kolibri',
container_command: null,
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
// 8080 = web UI. 8311 = zip-content server (interactive exercises / HTML5 apps), served
// from a separate "alternate origin" the browser connects to DIRECTLY. KOLIBRI_ZIP_CONTENT_PORT
// sets the port Kolibri both LISTENS on inside the container AND advertises in content URLs,
// so the internal port, the published host port, and that env value must all be identical
// (8311) — otherwise content URLs point at a host port that doesn't route to the listener
// and every content page fails with ERR_CONNECTION_REFUSED. The image's default 8081 is
// unused here. The image refuses to start without /kolibri mounted (KOLIBRI_HOME = /kolibri).
PortBindings: { '8080/tcp': [{ HostPort: '8310' }], '8311/tcp': [{ HostPort: '8311' }] },
Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/kolibri-gen2:/kolibri`],
PortBindings: { '8080/tcp': [{ HostPort: '8300' }] },
Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/kolibri:/root/.kolibri`],
},
ExposedPorts: { '8080/tcp': {}, '8311/tcp': {} },
Env: ['KOLIBRI_ZIP_CONTENT_PORT=8311'],
ExposedPorts: { '8080/tcp': {} },
}),
ui_location: '8310',
ui_location: '8300',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
@ -213,7 +198,7 @@ export default class ServiceSeeder extends BaseSeeder {
display_order: 20,
description: 'Locally-hosted PDF manipulation tool — merge, split, compress, convert, and more',
icon: 'IconFileDescription',
container_image: 'ghcr.io/stirling-tools/s-pdf:2.13.1',
container_image: 'ghcr.io/stirling-tools/s-pdf:latest',
source_repo: 'https://github.com/Stirling-Tools/Stirling-PDF',
container_command: null,
container_config: JSON.stringify({
@ -304,7 +289,7 @@ export default class ServiceSeeder extends BaseSeeder {
display_order: 22,
description: 'Web-based e-book reader and library manager for your Calibre collection',
icon: 'IconBook',
container_image: 'linuxserver/calibre-web:0.6.26-ls386',
container_image: 'lscr.io/linuxserver/calibre-web:latest',
source_repo: 'https://github.com/janeczku/calibre-web',
container_command: null,
container_config: JSON.stringify({
@ -335,7 +320,7 @@ export default class ServiceSeeder extends BaseSeeder {
display_order: 23,
description: 'Collection of handy utilities for developers — UUID, hash, encoding, formatters, and more',
icon: 'IconTool',
container_image: 'ghcr.io/corentinth/it-tools:2024.10.22-7ca5933',
container_image: 'ghcr.io/corentinth/it-tools:latest',
source_repo: 'https://github.com/CorentinTh/it-tools',
container_command: null,
container_config: JSON.stringify({
@ -360,7 +345,7 @@ export default class ServiceSeeder extends BaseSeeder {
display_order: 24,
description: 'Virtual whiteboard for sketching hand-drawn-style diagrams — works fully offline',
icon: 'IconPencil',
container_image: 'excalidraw/excalidraw:sha-4bfc5bb',
container_image: 'excalidraw/excalidraw:latest',
source_repo: 'https://github.com/excalidraw/excalidraw',
container_command: null,
container_config: JSON.stringify({
@ -385,7 +370,7 @@ export default class ServiceSeeder extends BaseSeeder {
display_order: 30,
description: 'Browser-based client for managing Meshtastic mesh radio devices',
icon: 'IconWifi',
container_image: 'ghcr.io/meshtastic/web:2.7.1',
container_image: 'ghcr.io/meshtastic/web:latest',
source_repo: 'https://github.com/meshtastic/web',
container_command: null,
container_config: JSON.stringify({
@ -405,35 +390,24 @@ export default class ServiceSeeder extends BaseSeeder {
depends_on: null,
},
{
service_name: SERVICE_NAMES.MESHCORE_WEB,
friendly_name: 'MeshCore Web',
powered_by: 'MeshCore',
display_order: 32,
description: 'Browser-based client for MeshCore mesh radio devices',
icon: 'IconAntenna',
// aXistem's prebuilt image of Liam Cottle's MeshCore web client (MeshCore is a sibling LoRa
// mesh project to Meshtastic).
container_image: 'ghcr.io/axistem-dev/meshcore-web:v1.45.0',
source_repo: 'https://github.com/aXistem-dev/meshcore-web',
service_name: SERVICE_NAMES.MESHTASTICD,
friendly_name: 'Meshtastic Daemon',
powered_by: 'Meshtastic',
display_order: 31,
description: 'Software-defined Meshtastic node with REST API — connect devices and build mesh networks',
icon: 'IconBroadcast',
container_image: 'meshtastic/meshtasticd:latest',
source_repo: 'https://github.com/meshtastic/meshtasticd',
container_command: null,
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
// The image is stock nginx:alpine serving the Flutter build over HTTP on 80. MeshCore's
// client reaches a radio via Web Bluetooth / Web Serial, which browsers only permit from a
// secure (HTTPS) context — so we serve it over HTTPS. _runPreinstallActions__MeshCoreWeb
// writes a self-signed cert + an SSL server config into storage/meshcore-web; we bind both
// in (the config over the image's default.conf) and publish 443. The https: prefix on
// ui_location builds an https:// Open link (one-time cert warning, same as Vaultwarden).
PortBindings: { '443/tcp': [{ HostPort: '8500' }] },
Binds: [
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/meshcore-web/nginx-ssl.conf:/etc/nginx/conf.d/default.conf:ro`,
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/meshcore-web/certs:/certs:ro`,
],
PortBindings: { '4403/tcp': [{ HostPort: '8460' }] },
Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/meshtasticd:/root/.portduino`],
},
ExposedPorts: { '443/tcp': {} },
ExposedPorts: { '4403/tcp': {} },
}),
ui_location: 'https:8500',
ui_location: '8460',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
@ -451,7 +425,7 @@ export default class ServiceSeeder extends BaseSeeder {
// Maintained fork. The original hay-kot/homebox was archived June 2024;
// sysadminsmedia is the official continuation (drop-in: same 7745 port + /data volume,
// migrates an existing DB forward, telemetry off by default).
container_image: 'ghcr.io/sysadminsmedia/homebox:0.26.2',
container_image: 'ghcr.io/sysadminsmedia/homebox:latest',
source_repo: 'https://github.com/sysadminsmedia/homebox',
container_command: null,
container_config: JSON.stringify({
@ -477,7 +451,7 @@ export default class ServiceSeeder extends BaseSeeder {
display_order: 26,
description: 'Lightweight Bitwarden-compatible password manager server — secure your credentials offline',
icon: 'IconShieldLock',
container_image: 'vaultwarden/server:1.36.0',
container_image: 'vaultwarden/server:latest',
source_repo: 'https://github.com/dani-garcia/vaultwarden',
container_command: null,
container_config: JSON.stringify({
@ -512,7 +486,7 @@ export default class ServiceSeeder extends BaseSeeder {
display_order: 27,
description: 'Open-source media server — stream your video, music, and photo libraries',
icon: 'IconMovie',
container_image: 'jellyfin/jellyfin:10.11.11',
container_image: 'jellyfin/jellyfin:latest',
source_repo: 'https://github.com/jellyfin/jellyfin',
container_command: null,
container_config: JSON.stringify({

View File

@ -73,7 +73,7 @@ This helps you balance content coverage against storage usage.
2. Type your question or request
3. The AI responds in conversational style
The AI must be installed first — enable it during Easy Setup or install it from the [Supply Depot](/supply-depot) page.
The AI must be installed first — enable it during Easy Setup or install it from the [Apps](/settings/apps) page.
### How do I upload documents to the Knowledge Base?
1. Go to **[Knowledge Base →](/knowledge-base)**
@ -104,7 +104,7 @@ The Early Access Channel lets you opt in to receive release candidate builds wit
2. Refresh the page (Ctrl+R or Cmd+R)
3. Go back to the Command Center and try again
4. Check Settings → System to see if the service is running
5. Try restarting the service (Stop, then Start in the Supply Depot)
5. Try restarting the service (Stop, then Start in Apps manager)
### Maps show a gray/blank area
@ -119,7 +119,7 @@ The Maps feature requires downloaded map data. If you see a blank area:
This usually means the Information Library service started before its Kiwix library index was fully initialized.
Try this recovery flow:
1. Go to **[Supply Depot](/supply-depot)**
1. Go to **[Apps](/settings/apps)**
2. Stop **Information Library (Kiwix)**
3. Wait 10-15 seconds, then start it again
4. If the error persists, run **Force Reinstall** for Information Library from the same page
@ -140,7 +140,7 @@ N.O.M.A.D. automatically detects NVIDIA GPUs when the NVIDIA Container Toolkit i
1. **Install an NVIDIA GPU** in your server (if not already present)
2. **Install the NVIDIA Container Toolkit** on the host — follow the [official installation guide](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html)
3. **Reinstall the AI Assistant** — Go to [Supply Depot](/supply-depot), find AI Assistant, and click **Force Reinstall**
3. **Reinstall the AI Assistant** — Go to [Apps](/settings/apps), find AI Assistant, and click **Force Reinstall**
N.O.M.A.D. will detect the GPU during installation and configure the AI to use it automatically. You'll see "NVIDIA container runtime detected" in the installation progress.
@ -151,7 +151,7 @@ N.O.M.A.D. will detect the GPU during installation and configure the AI to use i
When you add or swap a GPU, N.O.M.A.D. needs to reconfigure the AI container to use it:
1. Make sure the **NVIDIA Container Toolkit** is installed on the host
2. Go to **[Supply Depot](/supply-depot)**
2. Go to **[Apps](/settings/apps)**
3. Find the **AI Assistant** and click **Force Reinstall**
Force Reinstall recreates the AI container with GPU support enabled. Without this step, the AI continues to run on CPU only.
@ -163,7 +163,7 @@ N.O.M.A.D. checks whether your GPU is actually accessible inside the AI containe
### AI Chat not available
The AI Chat page requires the AI Assistant to be installed first:
1. Go to **[Supply Depot](/supply-depot)**
1. Go to **[Apps](/settings/apps)**
2. Install the **AI Assistant**
3. Wait for the installation to complete
4. The AI Chat will then be accessible from the home screen or [Chat](/chat)
@ -171,7 +171,7 @@ The AI Chat page requires the AI Assistant to be installed first:
### Knowledge Base upload stuck
If a document upload appears stuck in the Knowledge Base:
1. Check that the AI Assistant is running in **Settings → Supply Depot**
1. Check that the AI Assistant is running in **Settings → Apps**
2. Large documents take time to process — wait a few minutes
3. Try uploading a smaller document to verify the system is working
4. Check **Settings → System** for any error messages
@ -190,7 +190,7 @@ If submission fails, check the error message for details.
The service might still be starting up. Wait 1-2 minutes and try again.
If the problem persists:
1. Go to **Settings → Supply Depot**
1. Go to **Settings → Apps**
2. Find the problematic service
3. Click **Restart**
4. Wait 30 seconds, then try again
@ -233,17 +233,12 @@ Yes, while you have internet access. Updates include:
- Security improvements
- Performance enhancements
### Can N.O.M.A.D. update itself automatically?
Yes. N.O.M.A.D. can keep its software, its installed apps, and its content current on its own. Automatic updates are **opt-in and off by default** — you turn on what you want from **Settings → Updates** (and, for apps, a per-app toggle in the Supply Depot). They only run inside a time window you choose, after safety checks, and never apply major version jumps automatically. See the **[Updates guide](/docs/updates)** for a full walkthrough.
### How do I update content (Wikipedia, etc.)?
Content updates are separate from software updates:
1. Go to **Settings → Content Manager** or **Content Explorer**
2. Check for newer versions of your installed content
3. Download updated versions as needed
You can also turn on **automatic content updates** so installed Wikipedia/ZIM libraries and map regions refresh on their own overnight — see the [Updates guide](/docs/updates).
Tip: New Wikipedia snapshots are released approximately monthly.
### What happens if an update fails?

View File

@ -37,7 +37,7 @@ The Information Library stores compressed versions of websites and references th
- Classic books from Project Gutenberg
**How to use it:**
1. Click **Information Library** from the Command Center home screen or the [Supply Depot](/supply-depot)
1. Click **Information Library** from the Command Center home screen or [Apps](/settings/apps) page
2. Choose a collection (like Wikipedia)
3. Search or browse just like the regular website
@ -54,7 +54,7 @@ The Education Platform provides complete educational courses that work offline.
- Works for all ages
**How to use it:**
1. Click **Education Platform** from the Command Center home screen or the [Supply Depot](/supply-depot)
1. Click **Education Platform** from the Command Center home screen or [Apps](/settings/apps) page
2. Sign in or create a learner account
3. Browse courses and start learning
@ -82,9 +82,9 @@ N.O.M.A.D. includes a built-in AI chat interface powered by Ollama. It runs enti
**Tip:** Be specific in your questions. Instead of "tell me about plants," try "what vegetables grow well in shade?"
**Note:** The AI Assistant must be installed first. Enable it during Easy Setup or install it from the [Supply Depot](/supply-depot).
**Note:** The AI Assistant must be installed first. Enable it during Easy Setup or install it from the [Apps](/settings/apps) page.
**GPU Acceleration:** If your server has an NVIDIA GPU with the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) installed, N.O.M.A.D. will automatically use it for AI — dramatically faster responses (10-20x improvement). If you add a GPU later, go to the [Supply Depot](/supply-depot) and **Force Reinstall** the AI Assistant to enable it.
**GPU Acceleration:** If your server has an NVIDIA GPU with the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) installed, N.O.M.A.D. will automatically use it for AI — dramatically faster responses (10-20x improvement). If you add a GPU later, go to [Apps](/settings/apps) and **Force Reinstall** the AI Assistant to enable it.
---
@ -141,7 +141,7 @@ View maps without internet. Download the regions you need before going offline.
As your needs change, you can add more content anytime:
- **More apps:** Settings → Supply Depot
- **More apps:** Settings → Apps
- **More references:** Settings → Content Explorer or Content Manager
- **More map regions:** Settings → Maps Manager
- **More educational content:** Through Kolibri's built-in content browser
@ -184,8 +184,6 @@ While you have internet, periodically check for updates:
Content updates (Wikipedia, maps, etc.) can be managed separately from software updates.
**Automatic updates:** N.O.M.A.D. can also keep itself current without you having to check. Software, installed apps, and content can each be set to update automatically on an opt-in basis, with safety checks and a time window you control. See the **[Updates guide](/docs/updates)** for the full picture.
**Early Access Channel:** Want the latest features before they hit stable? Enable the Early Access Channel from the Check for Updates page to receive release candidate builds. You can switch back to stable anytime.
### Monitoring System Health

View File

@ -15,12 +15,12 @@ Think of it as having Wikipedia, Khan Academy, an AI assistant, and offline maps
### Browse Offline Knowledge
Access millions of Wikipedia articles, medical references, how-to guides, and ebooks — all stored locally on your server. No internet required.
*Launch the Information Library from the home screen or the [Supply Depot](/supply-depot).*
*Launch the Information Library from the home screen or the [Apps](/settings/apps) page.*
### Learn Something New
Khan Academy courses covering math, science, economics, and more. Complete with videos and exercises, all available offline.
*Launch the Education Platform from the home screen or the [Supply Depot](/supply-depot).*
*Launch the Education Platform from the home screen or the [Apps](/settings/apps) page.*
### Chat with AI
Ask questions, get explanations, brainstorm ideas, or get help with writing. Your local AI assistant works completely offline — and you can upload documents to the Knowledge Base for document-aware responses.
@ -60,7 +60,7 @@ Or explore the **[Getting Started Guide](/docs/getting-started)** for a walkthro
|--------------|---------|
| Chat with the AI | [AI Chat →](/chat) |
| Upload documents for AI | [Knowledge Base →](/knowledge-base) |
| Install more apps | [Supply Depot →](/supply-depot) |
| Download more content | [Install Apps →](/settings/apps) |
| Add Wikipedia/reference content | [Content Explorer →](/settings/zim/remote-explorer) |
| Manage installed content | [Content Manager →](/settings/zim) |
| Download map regions | [Maps Manager →](/settings/maps) |
@ -80,6 +80,4 @@ N.O.M.A.D. works best when kept up to date while you have internet access. This
When you go offline, you'll have everything you need — the last synced versions of all your content.
You can update on demand, or turn on **automatic updates** so N.O.M.A.D. keeps its software, apps, and content current on its own while you have internet. See the **[Updates guide](/docs/updates)** for how it works.
**[Check for Updates →](/settings/update)**

View File

@ -1,6 +1,6 @@
# Release Notes
## Version 1.33.0 - June 23, 2026
## Unreleased
### Features
- **Supply Depot — Custom Apps**: The Supply Depot is NOMAD's new home for installable apps, and it now lets you run your *own* custom Docker containers — not just the curated catalog. Specify an image, port mappings, volume binds, environment variables, and memory/CPU limits, and NOMAD spins it up as a managed sibling container. A live, debounced pre-flight check warns about port conflicts and resource limits as you type and hard-blocks unsafe configurations, with an "install anyway" override for warning-only cases (e.g. an untrusted registry or a `:latest` tag). Installed custom apps can be edited, updated (re-pull latest + recreate with a safe rollback if the new container fails), and removed (optionally deleting the image), and every installed app — curated or custom — gets per-container **Logs** and **Stats** modals. Host-path binds are hardened against escapes and logs/stats are scoped to NOMAD-managed containers only. Thanks @jakeaturner for the contribution!
@ -13,8 +13,6 @@
- **Maps — Persistent View**: The Maps page now remembers your position and zoom across refreshes instead of resetting to the default US-wide view. The saved view is bounds-checked, so a corrupt value safely falls back to the default. Thanks @chriscrosstalk for the contribution!
- **Content Manager — Rescan Library**: A new "Rescan Library" button rebuilds the Kiwix index from the ZIM files currently on disk, so files **sideloaded** outside NOMAD's download flow (USB stick, SSH, network share) can be served without dropping to a terminal. It reports how many new books were found and, in library mode, hot-reloads without a container restart. Thanks @chriscrosstalk for the contribution!
- **Configuration — Redis database selection**: Added a `REDIS_DB` environment variable so operators can pick a Redis logical database (015) for the job queue and live-update transport. This prevents key collisions when a single Redis instance is shared across multiple stacks (common in homelabs). Defaults to db 0, preserving existing behavior. Thanks @johno10661 for the contribution!
- **Advanced Settings — Internet Test URL**: Added a new Advanced Settings page with an option to override the default internet test "beacon" URL (more advanced settings to come). Previously, overriding this URL required an ENV variable change and container restart. The legacy ENV variable is still respected if you've set it. Thanks @jakeaturner for the contribution!
- **RAG**: Embedding jobs can now be cancelled, allowing users to clear stuck jobs that haven't explicitly failed. Thanks @jakeaturner for the contribution!
### Bug Fixes
- **Storage**: When the admin storage volume is relocated to another disk, child apps (Kiwix, Ollama, Qdrant, Flatnotes, Kolibri) now automatically follow to the new location instead of mounting the old, empty path. The host storage root is now derived from the admin's actual mount, with an explicit `NOMAD_STORAGE_PATH` override and clearer compose comments. Thanks @chriscrosstalk for the fix!
@ -43,8 +41,6 @@
- **Dependencies**: Bumped React and React DOM. Thanks @jakeaturner for the contribution!
- **Dependencies**: Bumped autoprefixer. Thanks @jakeaturner for the contribution!
- **Dependencies**: Bumped BullMQ to 5.77.6 and updated affected job calls to the new arguments shape. Thanks @jakeaturner for the contribution!
- **Supply Depot**: Pinned all curated image versions to ensure consistent baseline deployments. Thanks @jakeaturner for the contribution!
- **Supply Depot**: Bumped the default versions of CyberChef to 10.24.0 and Ollama to 0.24.0. Thanks @jakeaturner for the contribution!
## Version 1.32.1 - May 27, 2026

View File

@ -8,38 +8,6 @@ A quick note on logins: some of these apps have their own accounts, separate fro
---
## Managing your apps
Every app you install gets a **Manage** menu on its card. From there you can:
- **Docs** — jump straight to the NOMAD getting-started notes for that app (the same per-app sections you'll find below).
- **Edit** — change an app's settings: port mappings, volume binds, environment variables, and memory/CPU limits. This works for curated apps too, not just custom ones. Your edits are merged into the app's existing setup, so advanced settings (like GPU access on the AI Assistant) are preserved, and an edited app stops getting overwritten by catalog updates.
- **Logs** and **Stats** — open a live view of an app's log output or its current memory and CPU use, handy when something isn't behaving.
- **Update** and **Remove** — pull the latest version of an app, or remove it (optionally deleting its image too). If an update's new container fails to start, NOMAD automatically rolls back to the version that was working.
**Seeing what version you're running:** Each app card shows the installed version right next to the app name (for example, `Kiwix · 3.7.0`). When a newer version is available, an orange **Update available** pill appears on the card so it's easy to spot at a glance.
**Custom "Open" links:** By default the **Open** button points at the app on your NOMAD's own address. If you run a reverse proxy or local DNS and would rather open an app at a friendlier address (for example `https://jellyfin.myhomelab.net`), use **Manage Edit** to set a custom launch URL. NOMAD keeps your original link safely on file, so you can always switch back, and the override sticks across upgrades.
**Keeping apps updated automatically:** Installed apps can update themselves hands-off. This is opt-in at two levels — a master switch in **Settings → Updates** and a per-app toggle in the Supply Depot — and only minor and patch updates are ever applied automatically (major versions always stay manual). See the [Updates guide](/docs/updates) for the full story.
---
## Bringing your own app
Beyond the curated catalog, the Supply Depot can run **your own Docker container** as a managed app alongside everything else. Click **Add a custom app** and tell NOMAD:
- the **image** to pull (for example `ghcr.io/owner/app:1.2.3`),
- any **port mappings**, **volume binds**, **environment variables**, and **memory/CPU limits** it needs.
As you fill it in, NOMAD runs a live pre-flight check and warns you about things like port conflicts or risky settings. Some warnings (an untrusted registry, or a `:latest` tag that can't be version-tracked) are advisory and you can choose **Install anyway**; genuinely unsafe configurations are blocked outright.
Once installed, a custom app behaves like any other: it gets the same **Manage** menu (Edit, Logs, Stats, Update, Remove), shows its version on the card, and can opt in to automatic updates. NOMAD hardens host-path binds and scopes logs and stats to its own managed containers, so a custom app can't reach outside what you give it.
> A custom app is exactly that — yours. NOMAD runs it and gets out of the way; it doesn't provide setup docs or support for software outside the curated catalog. Check the project's own documentation for how to use it.
---
## Stirling PDF {% #stirling-pdf %}
A full toolbox for working with PDFs, all on your own hardware. Merge and split files, convert to and from PDF, compress, rotate, add or remove passwords, OCR scanned documents so they're searchable, sign, stamp, and redact. There are over 50 tools in here, and because it runs locally, none of your documents ever leave your NOMAD.
@ -242,43 +210,3 @@ A browser-based control panel for [Meshtastic](https://meshtastic.org) devices.
**Your data:** There's nothing to set up or store on your NOMAD for this app. Your radio's settings live on the radio itself, and this app's preferences live in your browser. There's no NOMAD folder to manage.
**Works offline:** Fully offline, which is the entire point of Meshtastic. The app is served from your NOMAD, and talking to your radios happens over your local network or radio, never the internet. The only online bits are the links in the footer (Vercel, legal), which don't matter for using your mesh.
## Education Platform (Kolibri) {% #kolibri %}
A complete offline learning platform from Learning Equality. Kolibri pulls together video lessons, exercises, and readings into structured channels, organizes them into classes and lessons, tracks learner progress, and works entirely on your NOMAD with no internet. It's built for schools and learners in places with little or no connectivity.
**Official site:** [learningequality.org/kolibri](https://learningequality.org/kolibri) · **Source:** [github.com/learningequality/kolibri](https://github.com/learningequality/kolibri)
**First time you open it, you'll go through a quick setup wizard.** Pick your facility type and create the **admin account** (this is the super-user that manages the whole device, so give it a real password and keep track of it). Once you're in, you import learning content as **channels**.
**Importing content:** Kolibri's content is delivered as channels you import. Open **Device → Channels → Import**, and either pull channels from Kolibri Studio (online) or import from a local drive or another Kolibri device if you already have the content files. There's a lot available, so import just the channels you need; they can be large.
**Migrating content from Education Platform (Gen 1):** Earlier NOMAD releases shipped a much older Kolibri (the `treehouses/kolibri:0.12.8` image). The Education Platform "Gen 2" is a newer, upstream-official Kolibri and installs **fresh** — your old channels and learner data are **not** carried over automatically, because the two versions store data too differently to migrate safely. If you were running the old one and want to import your existing channels into the new one, here's the process:
1. Install "Education Platform (Gen 2)" from the catalog (it runs alongside the old one on a different port, so nothing is disrupted while you set it up).
2. Launch the new one, walk through the setup wizard, then from the sidebar menu, navigate to **Device > Channels > Import**. Choose the "Local network or internet" option, and then "Add new device". In the dialog that appears, enter the IP address of your NOMAD with the old Education Platform port (8300 by default, so for example `http://192.168.1.36:8300`), give it a name (anything you'd like), and click "Add", and then "Continue".
3. You can now select individual channels from the old Education Platform, or choose "Select entire channels instead" to import everything at once. Click "Import" when ready, and the transfer will start.
3. Once you're happy with the new install and have any content copied over, uninstall the old Education Platform from its card (it carries a **legacy** badge). It's also recommended to choose to remove the old image and data volume when uninstalling to avoid confusion and free up space, but if you want to keep it around for a while just in case, that's totally fine too.
**Your data:** Your imported channels, classes, and learner progress live in the `storage/kolibri-gen2` folder on your NOMAD. Backing up that folder backs up your whole Kolibri.
**Works offline:** Fully offline once content is imported, that's what Kolibri is for. The only step that uses the internet is importing channels from Kolibri Studio; everything after that, browsing lessons, doing exercises, tracking progress, runs entirely on your NOMAD.
## MeshCore Web {% #meshcore-web %}
A browser-based client for [MeshCore](https://meshcore.co.uk) radios. MeshCore is another take on off-grid, long-range LoRa mesh messaging, a sibling to Meshtastic: small radios that form their own network and pass text and location for miles with no cell service, no internet, and no fees. This app is how you configure a MeshCore radio and read and send messages from a full-size screen. If you're not already running MeshCore gear, the Meshtastic client above is the more common starting point. This one is here for people who use MeshCore.
**Official site:** [meshcore.co.uk](https://meshcore.co.uk) · **Source:** [github.com/aXistem-dev/meshcore-web](https://github.com/aXistem-dev/meshcore-web) (a packaged build of Liam Cottle's MeshCore client)
**You need a MeshCore radio to use this.** Like the Meshtastic client, this is just the control panel. With no radio connected, there's nothing for it to talk to.
**First time you open it, you'll see a security warning. That's expected, here's why:** MeshCore connects to your radio over USB or Bluetooth, and browsers only let a web page use USB or Bluetooth when the page is loaded over a secure (HTTPS) connection. So NOMAD serves this app over HTTPS, and because your NOMAD is a private device with no public web address, it uses a self-signed certificate that browsers warn about the first time they see it. To get past it once:
1. Click **Open** on the MeshCore Web card. Your browser shows something like *"Your connection is not private"* or *"Not secure."*
2. Click **Advanced**, then **Proceed to (your NOMAD's address)**. (On some browsers the button says "Continue" or "Accept the Risk.")
3. You'll land in MeshCore Web. Your browser remembers your choice, so you won't see the warning again on that device.
**Connecting your radio:** Use **Chrome or Edge**, which have the best support for browser USB and Bluetooth. Plug the radio into the computer you're browsing from (USB), or have it nearby (Bluetooth), then connect to it from inside the app. The radio connects to **the computer you're using**, not to the NOMAD itself, so connect from a device that has the radio plugged in or in Bluetooth range. Some phones are stricter about self-signed certificates and may refuse to connect; a desktop Chrome or Edge is the most reliable.
**Your data:** There's nothing to set up or store on your NOMAD for this app. Your radio's settings live on the radio itself, and the app's preferences live in your browser. There's no NOMAD folder to manage.
**Works offline:** Fully offline, which is the whole point of MeshCore. The app is served from your NOMAD and talks to your radio directly over USB or Bluetooth, never the internet.

View File

@ -1,67 +0,0 @@
# Keeping N.O.M.A.D. Updated
N.O.M.A.D. works best when it's kept current while you have internet, so it's ready with the latest software and content the next time you go offline. This page explains what can be updated, how to do it on demand, and how to let N.O.M.A.D. handle it for you automatically.
---
## The three kinds of updates
There are three separate things that can be updated, and you control each one independently:
1. **Software (the core)** — N.O.M.A.D. itself: the Command Center, new features, bug fixes, and security improvements.
2. **Apps** — the installable apps from the [Supply Depot](/supply-depot) (Kiwix, the AI Assistant, and any others you've added).
3. **Content** — your offline material: Wikipedia and other Kiwix libraries, and downloaded map regions.
You can update any of these on demand, or set any of them to update automatically.
---
## Updating on demand
To check for and install updates yourself:
1. Go to **[Settings → Check for Updates](/settings/update)**.
2. If a software update is available, click to install it. N.O.M.A.D. downloads the update and restarts (usually 25 minutes).
3. Apps can be updated from their card in the [Supply Depot](/supply-depot) using **Manage Update**.
4. Content is managed from **Settings → Content Manager** and **Content Explorer**, where you can download newer versions of installed libraries and maps.
If a software or app update ever fails, N.O.M.A.D. is designed to recover gracefully — the previous working version keeps running, so your server stays up.
---
## Automatic updates
N.O.M.A.D. can keep itself current without you having to remember to check. **Automatic updates are opt-in and off by default** — nothing updates on its own until you turn it on. You manage all of it from **Settings → Updates**.
A few things are true across all three:
- **You choose a time window.** Automatic updates only run during the hours you set, so they never interrupt you mid-use.
- **Major versions are never automatic.** Only minor and patch updates apply on their own; a big version jump always waits for you to do it manually, on purpose.
- **Safety checks come first.** Before applying anything, N.O.M.A.D. confirms there's enough disk space and that no other update, download, or install is already in progress.
- **Being offline is harmless.** If N.O.M.A.D. can't reach the internet to check, it simply skips that round and tries again later.
### Automatic software (core) updates
Turn this on from **Settings → Updates**. When enabled, N.O.M.A.D. updates its own core to newer releases within the same major version, during your chosen window, after a configurable **cool-off** period (so a brand-new release has time to prove itself before your server takes it). The same page shows the toggle, the window, the cool-off setting, and live status. If updates fail repeatedly for a real reason, N.O.M.A.D. turns the feature back off and lets you know rather than retrying forever.
### Automatic app updates
App auto-updates are opt-in at **two levels**: a master switch in **Settings → Updates**, *and* a per-app toggle on each app's card in the [Supply Depot](/supply-depot). Both have to be on for an app to update itself. App updates share the same update window and cool-off as the core, apply only minor and patch versions, and back off automatically for any individual app that keeps failing.
### Automatic content updates
Installed Wikipedia/ZIM libraries and map regions can refresh themselves too. Because content downloads are large (often many gigabytes), content updates run on their **own dedicated overnight window** with a **bandwidth cap**, separate from the software and app schedule. N.O.M.A.D. checks the upstream Kiwix and map catalogs directly, and when a Wikipedia library is replaced with a newer version, it keeps the AI Knowledge Base in sync automatically.
---
## Early Access Channel
Want new features before they reach the stable release? Enable the **Early Access Channel** from the [Check for Updates](/settings/update) page to receive release-candidate builds. Early-access builds may contain rough edges — you can switch back to stable at any time.
---
## Before you go offline
Whatever you choose, the habit that matters most is simple: **update while you still have internet.** Whether you do it by hand or let automatic updates handle it, make sure your software and content are current before you head somewhere without a connection. When you're offline, you'll have the last synced versions of everything ready to go.
**[Check for Updates →](/settings/update)** · **[See what's new in each version →](/docs/release-notes)**

View File

@ -8,7 +8,7 @@ interface LoadingSpinnerProps {
const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
text,
fullscreen = false,
fullscreen = true,
iconOnly = false,
light = false,
className,
@ -29,12 +29,9 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
}
return (
<div
className={`fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm ${className || ''}`}
>
<div className="flex flex-col items-center justify-center">
<div className="w-10 h-10 border-[3px] border-white border-t-transparent rounded-full animate-spin" />
{!iconOnly && <div className="text-white mt-3 font-medium">{text || 'Loading'}</div>}
<div className={className}>
<div className="ui active inverted dimmer">
<div className="ui text loader">{!iconOnly && <span>{text || 'Loading'}</span>}</div>
</div>
</div>
)

View File

@ -1,95 +0,0 @@
import Uppy from '@uppy/core'
import Dashboard from '@uppy/react/dashboard'
import XHRUpload from '@uppy/xhr-upload'
import '@uppy/core/css/style.min.css'
import '@uppy/dashboard/css/style.min.css'
import { useEffect, useRef, useState } from 'react'
interface ZimUploaderProps {
onUploadComplete: (added: number) => void
existingFilenames: string[]
}
export default function ZimUploader({ onUploadComplete, existingFilenames }: ZimUploaderProps) {
const existingFilenamesRef = useRef(existingFilenames)
useEffect(() => {
existingFilenamesRef.current = existingFilenames
}, [existingFilenames])
// Keep the latest callback in a ref so the Uppy lifecycle effect below does
// not depend on it. The parent passes a new inline function on every render,
// and if that identity were in the effect deps, a re-render mid-upload (e.g.
// a window-focus refetch) would tear down and `uppy.destroy()` the instance,
// aborting the in-flight upload.
const onUploadCompleteRef = useRef(onUploadComplete)
useEffect(() => {
onUploadCompleteRef.current = onUploadComplete
}, [onUploadComplete])
const [uppy] = useState(() =>
new Uppy({
restrictions: {
maxNumberOfFiles: 5,
allowedFileTypes: ['.zim'],
},
autoProceed: false,
}).use(XHRUpload, {
endpoint: '/api/zim/upload',
fieldName: 'file',
getResponseError: (responseText) => {
try {
const body = JSON.parse(responseText)
if (body?.message) return new Error(body.message)
} catch {}
return new Error('Upload failed')
},
})
)
useEffect(() => {
const handleFileAdded = (file: { id: string; name: string }) => {
if (existingFilenamesRef.current.includes(file.name)) {
uppy.removeFile(file.id)
uppy.info('A ZIM file with that name already exists', 'error', 6000)
return
}
const isWikipedia = (name: string) => name.startsWith('wikipedia_en_')
if (isWikipedia(file.name)) {
const alreadyQueued = uppy.getFiles().some((f) => f.id !== file.id && isWikipedia(f.name))
if (alreadyQueued) {
uppy.removeFile(file.id)
uppy.info('Only one Wikipedia file can be uploaded at a time', 'error', 6000)
}
}
}
const handleComplete = (result: {
successful: Array<{ response?: { body?: { added?: number } } }>
failed: Array<unknown>
}) => {
// Uppy emits `complete` even when uploads fail or are aborted. Only treat
// the run as a success when nothing failed — otherwise leave the uploader
// open so the Dashboard can surface the error instead of closing it with a
// false "Upload complete" toast.
if (result.failed.length > 0) return
const added = result.successful.reduce((sum, f) => sum + (f.response?.body?.added ?? 0), 0)
onUploadCompleteRef.current(added)
}
uppy.on('file-added', handleFileAdded)
uppy.on('complete', handleComplete)
return () => {
uppy.off('file-added', handleFileAdded)
uppy.off('complete', handleComplete)
uppy.destroy()
}
}, [uppy])
return (
<Dashboard
uppy={uppy}
width="100%"
height={300}
note="ZIM files only. Large files (up to 20 GB) are supported. For best results, upload from the same machine or over a stable LAN connection. Larger files should be copied directly to the storage volume"
/>
)
}

View File

@ -10,19 +10,9 @@ import api from '~/lib/api'
import {
groupAndSortKbFiles,
type KbFileGroup,
type KbFileSort,
type KbFileSortKey,
} from '~/lib/kb_file_grouping'
import type { KbIngestStateValue } from '../../../types/kb_ingest_state'
import { formatBytes } from '~/lib/util'
import {
IconArrowsSort,
IconDownload,
IconEye,
IconSortAscending,
IconSortDescending,
IconX,
} from '@tabler/icons-react'
import { IconX } from '@tabler/icons-react'
import { useModals } from '~/context/ModalContext'
import StyledModal from '../StyledModal'
import ActiveEmbedJobs from '~/components/ActiveEmbedJobs'
@ -33,42 +23,6 @@ interface KnowledgeBaseModalProps {
onClose: () => void
}
// File extensions the in-browser viewer can render. Must stay in sync with
// `RagService.VIEWABLE_TEXT_EXTENSIONS` — anything outside this set falls back
// to Download.
const VIEWABLE_EXTENSIONS = new Set(['md', 'txt', 'csv', 'json', 'yaml', 'yml', 'toml', 'xml', 'html'])
function isViewableExtension(filename: string): boolean {
const ext = filename.split('.').at(-1)?.toLowerCase() ?? ''
return VIEWABLE_EXTENSIONS.has(ext)
}
function renderSortHeader(
label: string,
key: KbFileSortKey,
sort: KbFileSort,
setSort: (s: KbFileSort) => void
): React.ReactNode {
const active = sort.key === key
const Icon = !active ? IconArrowsSort : sort.direction === 'asc' ? IconSortAscending : IconSortDescending
return (
<button
type="button"
className="inline-flex items-center gap-1 text-left hover:text-text-primary transition-colors"
onClick={() => {
if (!active) {
setSort({ key, direction: 'asc' })
} else {
setSort({ key, direction: sort.direction === 'asc' ? 'desc' : 'asc' })
}
}}
>
<span>{label}</span>
<Icon size={14} className={active ? 'text-text-primary' : 'text-text-muted'} aria-hidden="true" />
</button>
)
}
/**
* Compact label for the per-row ingestion state. Files that exist in Qdrant
* with no `kb_ingest_state` row (`state === null`) are legacy/pre-RFC-883
@ -147,8 +101,6 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
const [confirmReembed, setConfirmReembed] = useState<{ source: string; displayName: string } | null>(null)
const [bulkMode, setBulkMode] = useState<null | 'reembed' | 'reset'>(null)
const [resetTyped, setResetTyped] = useState('')
const [sort, setSort] = useState<KbFileSort>({ key: 'name', direction: 'asc' })
const [viewerSource, setViewerSource] = useState<string | null>(null)
const fileUploaderRef = useRef<React.ComponentRef<typeof FileUploader>>(null)
const { openModal, closeModal } = useModals()
const queryClient = useQueryClient()
@ -262,20 +214,6 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
},
})
const cancelAllMutation = useMutation({
mutationFn: () => api.cancelAllEmbedJobs(),
onSuccess: (data) => {
addNotification({ type: 'success', message: data?.message || 'All embedding jobs cancelled.' })
queryClient.invalidateQueries({ queryKey: ['embed-jobs'] })
queryClient.invalidateQueries({ queryKey: ['failedEmbedJobs'] })
queryClient.invalidateQueries({ queryKey: ['storedFiles'] })
queryClient.invalidateQueries({ queryKey: ['kbFileWarnings'] })
},
onError: (error: any) => {
addNotification({ type: 'error', message: error?.message || 'Failed to cancel jobs.' })
},
})
const startQdrantMutation = useMutation({
mutationFn: () => api.affectService(SERVICE_NAMES.QDRANT, 'start'),
onSuccess: () => {
@ -372,31 +310,6 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
}
}
const handleConfirmCancelAll = () => {
openModal(
<StyledModal
title='Cancel All Embedding Jobs?'
onConfirm={() => {
cancelAllMutation.mutate()
closeModal('confirm-cancel-all-modal')
}}
onCancel={() => closeModal('confirm-cancel-all-modal')}
open={true}
confirmText='Cancel All Jobs'
cancelText='Keep Jobs'
confirmVariant='danger'
>
<p className='text-text-primary'>
This stops <strong>every</strong> embedding job including ones still in progress or
stuck and clears the processing queue. The uploaded source files for those jobs are
deleted, so you'll need to re-upload anything you still want indexed. Stored files that
already finished embedding are not affected. Are you sure you want to proceed?
</p>
</StyledModal>,
'confirm-cancel-all-modal'
)
}
const handleConfirmSync = () => {
openModal(
<StyledModal
@ -572,33 +485,18 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
</div>
<div className="my-8">
<div className="flex items-center justify-between mb-4 gap-2 flex-wrap">
<div className="flex items-center justify-between mb-4">
<StyledSectionHeader title="Processing Queue" className="!mb-0" />
<div className="flex items-center gap-2 flex-wrap">
<StyledButton
variant="danger"
size="md"
icon="IconTrash"
onClick={() => cleanupFailedMutation.mutate()}
loading={cleanupFailedMutation.isPending}
disabled={cleanupFailedMutation.isPending || qdrantOffline}
>
Clean Up Failed
</StyledButton>
{/* Not gated on qdrantOffline: clearing stuck jobs must work during
a Qdrant/Ollama outage, which is exactly when they wedge. */}
<StyledButton
variant="danger"
size="md"
icon="IconPlayerStop"
onClick={handleConfirmCancelAll}
loading={cancelAllMutation.isPending}
disabled={cancelAllMutation.isPending}
title="Stop and clear every embedding job regardless of state, including stuck or in-progress ones. Deletes the uploaded source files for those jobs."
>
Cancel All Jobs
</StyledButton>
</div>
<StyledButton
variant="danger"
size="md"
icon="IconTrash"
onClick={() => cleanupFailedMutation.mutate()}
loading={cleanupFailedMutation.isPending}
disabled={cleanupFailedMutation.isPending || qdrantOffline}
>
Clean Up Failed
</StyledButton>
</div>
<ActiveEmbedJobs withHeader={false} />
</div>
@ -657,7 +555,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
columns={[
{
accessor: 'source',
title: renderSortHeader('File Name', 'name', sort, setSort),
title: 'File Name',
render(record) {
const warnings = fileWarnings[record.source] ?? []
const pill = renderStatePill(record)
@ -696,35 +594,6 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
)
},
},
{
accessor: 'size',
title: renderSortHeader('Size', 'size', sort, setSort),
className: 'whitespace-nowrap',
render(record) {
// The collapsed admin_docs group has no single size — leave blank
// rather than misleadingly summing across N files.
if (record.bucket === 'admin_docs' || record.size === null) {
return <span className="text-text-muted"></span>
}
return <span className="text-text-secondary">{formatBytes(record.size)}</span>
},
},
{
accessor: 'uploadedAt',
title: renderSortHeader('Uploaded', 'uploadedAt', sort, setSort),
className: 'whitespace-nowrap',
render(record) {
if (record.bucket === 'admin_docs' || !record.uploadedAt) {
return <span className="text-text-muted"></span>
}
const d = new Date(record.uploadedAt)
return (
<span className="text-text-secondary" title={d.toISOString()}>
{d.toLocaleDateString()}
</span>
)
},
},
{
accessor: 'source',
title: '',
@ -773,9 +642,6 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
const actionPendingForThisRow =
embedMutation.isPending && embedMutation.variables?.source === record.source
const canView = record.isUserUpload && isViewableExtension(record.displayName) && record.size !== null
const canDownload = record.isUserUpload && record.size !== null
return (
<div className="flex justify-end items-center gap-2">
{action && (
@ -796,24 +662,6 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
{action.label}
</StyledButton>
)}
{canView && (
<StyledButton
variant="ghost"
size="sm"
icon="IconEye"
onClick={() => setViewerSource(record.source)}
>View</StyledButton>
)}
{canDownload && (
<StyledButton
variant="ghost"
size="sm"
icon="IconDownload"
onClick={() => {
window.location.href = `/api/rag/files/download?source=${encodeURIComponent(record.source)}`
}}
>Download</StyledButton>
)}
<StyledButton
variant="danger"
size="sm"
@ -827,7 +675,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
},
},
]}
data={groupAndSortKbFiles(storedFiles, sort)}
data={groupAndSortKbFiles(storedFiles)}
loading={isLoadingFiles}
/>
</div>
@ -959,57 +807,6 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
</div>
</StyledModal>
)}
{viewerSource && (
<FileViewerModal
source={viewerSource}
onClose={() => setViewerSource(null)}
/>
)}
</div>
)
}
function FileViewerModal({ source, onClose }: { source: string; onClose: () => void }) {
const { data, isLoading, isFetched } = useQuery({
queryKey: ['rag', 'file-content', source],
queryFn: () => api.getFileContent(source),
staleTime: 60_000,
})
// Title falls back to the trailing path segment so the modal still has a
// useful header while the fetch is in-flight or if it failed.
const fallbackName = source.split(/[/\\]/).at(-1) ?? source
const title = data?.fileName ?? fallbackName
// `catchInternal` swallows errors and resolves to undefined, surfacing a
// toast — so the "couldn't load" branch is gated on a finished-but-empty
// fetch rather than on react-query's `isError`.
const showError = isFetched && !data
return (
<StyledModal
title={title}
open={true}
onClose={onClose}
onCancel={onClose}
cancelText="Close"
large
>
<div className="text-left text-sm">
{isLoading && (
<div className="text-text-secondary">Loading</div>
)}
{showError && (
<div className="text-amber-700 dark:text-amber-300">
Couldn't load file. It may have been moved or its type isn't viewable.
</div>
)}
{data && (
<pre className="max-h-[60vh] overflow-auto whitespace-pre-wrap rounded border border-border-subtle bg-surface-secondary p-3 font-mono text-xs text-text-primary">
{data.content}
</pre>
)}
</div>
</StyledModal>
)
}

View File

@ -1,7 +1,5 @@
import {
IconAdjustments,
IconArrowBigUpLines,
IconBox,
IconChartBar,
IconDashboard,
IconFolder,
@ -9,6 +7,7 @@ import {
IconHeart,
IconMapRoute,
IconSettings,
IconTerminal2,
IconWand,
IconZoom
} from '@tabler/icons-react'
@ -24,7 +23,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
const navigation = [
...(aiAssistantInstallStatus.isInstalled ? [{ name: aiAssistantName, href: '/settings/models', icon: IconWand, current: false }] : []),
{ name: 'Supply Depot', href: '/supply-depot', icon: IconBox, current: false },
{ name: 'Supply Depot', href: '/supply-depot', icon: IconTerminal2, current: false },
{ name: 'Benchmark', href: '/settings/benchmark', icon: IconChartBar, current: false },
{ name: 'Content Explorer', href: '/settings/zim/remote-explorer', icon: IconZoom, current: false },
{ name: 'Content Manager', href: '/settings/zim', icon: IconFolder, current: false },
@ -43,7 +42,6 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
current: false,
},
{ name: 'System', href: '/settings/system', icon: IconSettings, current: false },
{ name: 'Advanced', href: '/settings/advanced', icon: IconAdjustments, current: false },
{ name: 'Support the Project', href: '/settings/support', icon: IconHeart, current: false },
{ name: 'Legal Notices', href: '/settings/legal', icon: IconGavel, current: false },
]

View File

@ -479,13 +479,6 @@ class API {
})()
}
async cancelAllEmbedJobs(): Promise<{ message: string; cancelled: number; filesDeleted: number } | undefined> {
return catchInternal(async () => {
const response = await this.client.delete<{ message: string; cancelled: number; filesDeleted: number }>('/rag/jobs')
return response.data
})()
}
async checkRAGHealth() {
return catchInternal(async () => {
const response = await this.client.get<{ online: boolean; message?: string }>('/rag/health')
@ -521,17 +514,6 @@ class API {
})()
}
async getFileContent(source: string) {
return catchInternal(async () => {
const response = await this.client.get<{
content: string
extension: string
fileName: string
}>('/rag/files/content', { params: { source } })
return response.data
})()
}
async getSystemInfo() {
return catchInternal(async () => {
const response = await this.client.get<SystemInformationResponse>('/system/info')
@ -1096,16 +1078,6 @@ class API {
})()
}
async uninstallService(service_name: string, remove_image = false) {
return catchInternal(async () => {
const response = await this.client.post<{ success: boolean; message: string }>(
'/system/services/uninstall',
{ service_name, remove_image }
)
return response.data
})()
}
async updateCustomAppImage(service_name: string) {
return catchInternal(async () => {
const response = await this.client.post<{ success: boolean; message: string }>(

View File

@ -1,5 +1,4 @@
import {
IconAntenna,
IconArrowUp,
IconBook,
IconBooks,
@ -73,7 +72,6 @@ import {
* very limited subset of the full Tabler Icons library.
*/
export const icons = {
IconAntenna,
IconAlertTriangle,
IconArrowLeft,
IconArrowRight,

View File

@ -52,62 +52,19 @@ export interface KbFileGroup {
/** Chunks currently embedded for this source; 0 for state-row-less or
* zero-chunk files. Always 0 for the collapsed admin_docs group. */
chunksEmbedded: number
/** File size in bytes from disk. Null for the collapsed admin_docs group,
* and for any file the scanner couldn't stat. */
size: number | null
/** Last-modified timestamp (ISO 8601). Null for collapsed groups and for
* files the scanner couldn't stat. */
uploadedAt: string | null
/** True when the row corresponds to a user upload drives whether the
* view/download buttons render. False for the collapsed admin_docs group. */
isUserUpload: boolean
}
const BUCKET_SORT_ORDER: KbFileBucket[] = ['zim', 'upload', 'admin_docs', 'other']
export type KbFileSortKey = 'name' | 'size' | 'uploadedAt'
export type KbFileSortDirection = 'asc' | 'desc'
export interface KbFileSort {
key: KbFileSortKey
direction: KbFileSortDirection
}
const DEFAULT_SORT: KbFileSort = { key: 'name', direction: 'asc' }
function compareForSort(a: StoredFileInfo, b: StoredFileInfo, sort: KbFileSort): number {
// Files the scanner couldn't stat sort to the end regardless of direction so
// they don't pollute the top of size/uploaded-at views.
const aMissing = sort.key !== 'name' && (sort.key === 'size' ? a.size === null : a.uploadedAt === null)
const bMissing = sort.key !== 'name' && (sort.key === 'size' ? b.size === null : b.uploadedAt === null)
if (aMissing && !bMissing) return 1
if (!aMissing && bMissing) return -1
let cmp = 0
if (sort.key === 'size') {
cmp = (a.size ?? 0) - (b.size ?? 0)
} else if (sort.key === 'uploadedAt') {
cmp = (a.uploadedAt ?? '').localeCompare(b.uploadedAt ?? '')
}
if (cmp === 0) {
// Tiebreak (and primary key for 'name') is filename — keeps stable order.
cmp = sourceToDisplayName(a.source).localeCompare(sourceToDisplayName(b.source))
}
return sort.direction === 'desc' ? -cmp : cmp
}
/**
* Group stored-file rows into table rows for the Stored Files panel.
*
* - Admin docs (`/app/docs/*`, README) collapse into a single
* "Project NOMAD documentation · N files" row.
* - ZIMs, uploads, and others stay as individual rows, sorted within their
* bucket by the active sort key. Bucket order itself is fixed sorting
* never flattens or reorders the groups themselves.
* - ZIMs, uploads, and others stay as individual rows, sorted by bucket then
* alphabetically by filename so related items cluster naturally.
*/
export function groupAndSortKbFiles(
files: StoredFileInfo[],
sort: KbFileSort = DEFAULT_SORT
): KbFileGroup[] {
export function groupAndSortKbFiles(files: StoredFileInfo[]): KbFileGroup[] {
const buckets: Record<KbFileBucket, StoredFileInfo[]> = {
zim: [],
upload: [],
@ -133,14 +90,13 @@ export function groupAndSortKbFiles(
members: members.map((m) => m.source),
state: null,
chunksEmbedded: 0,
size: null,
uploadedAt: null,
isUserUpload: false,
})
continue
}
for (const file of members.sort((a, b) => compareForSort(a, b, sort))) {
for (const file of members.sort((a, b) =>
sourceToDisplayName(a.source).localeCompare(sourceToDisplayName(b.source))
)) {
groups.push({
bucket,
source: file.source,
@ -149,9 +105,6 @@ export function groupAndSortKbFiles(
members: [],
state: file.state,
chunksEmbedded: file.chunksEmbedded,
size: file.size,
uploadedAt: file.uploadedAt,
isUserUpload: file.isUserUpload,
})
}
}

View File

@ -61,7 +61,7 @@ function buildCoreCapabilities(aiAssistantName: string): Capability[] {
'Interactive exercises and quizzes',
'Progress tracking for learners',
],
services: [SERVICE_NAMES.KOLIBRI_GEN2],
services: [SERVICE_NAMES.KOLIBRI],
icon: 'IconSchool',
},
{

View File

@ -1,8 +1,8 @@
import {
IconBolt,
IconBox,
IconHelp,
IconMapRoute,
IconPlus,
IconSettings,
IconWifiOff,
} from '@tabler/icons-react'
@ -46,7 +46,7 @@ const SYSTEM_ITEMS = [
to: '/supply-depot',
target: '',
description: 'Browse and install curated apps, or add your own Docker container',
icon: <IconBox size={48} />,
icon: <IconPlus size={48} />,
installed: true,
displayOrder: 51,
poweredBy: null,

View File

@ -1,130 +0,0 @@
import { Head } from '@inertiajs/react'
import { useState } from 'react'
import SettingsLayout from '~/layouts/SettingsLayout'
import StyledButton from '~/components/StyledButton'
import StyledSectionHeader from '~/components/StyledSectionHeader'
import Alert from '~/components/Alert'
import Input from '~/components/inputs/Input'
import { useNotifications } from '~/context/NotificationContext'
import { useMutation } from '@tanstack/react-query'
import api from '~/lib/api'
export default function AdvancedPage(props: {
advanced: {
internetStatusTestUrl: string
internetStatusTestUrlEnvOverride: boolean
}
}) {
const { addNotification } = useNotifications()
const { internetStatusTestUrlEnvOverride } = props.advanced
const [internetStatusTestUrl, setInternetStatusTestUrl] = useState(
props.advanced.internetStatusTestUrl ?? ''
)
const [testUrlError, setTestUrlError] = useState<string | null>(null)
// Mirror the backend validation (admin/app/validators/settings.ts) for instant
// feedback. The backend remains the source of truth and returns 422 on failure.
function validateTestUrl(value: string): string | null {
if (value.trim() === '') return null // empty clears the setting
try {
const url = new URL(value)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return 'Test URL must use http or https.'
}
} catch {
return 'Test URL must be a valid URL (e.g. "https://example.com").'
}
return null
}
const updateTestUrlMutation = useMutation({
mutationFn: async (value: string) => {
return await api.updateSetting('system.internetStatusTestUrl', value)
},
onSuccess: () => {
addNotification({ message: 'Setting updated successfully.', type: 'success' })
},
onError: (error: any) => {
const msg =
error?.response?.data?.message ||
error?.message ||
'There was an error updating the setting. Please try again.'
setTestUrlError(msg)
addNotification({ message: msg, type: 'error' })
},
})
function handleSaveTestUrl() {
const trimmed = internetStatusTestUrl.trim()
const validationError = validateTestUrl(trimmed)
if (validationError) {
setTestUrlError(validationError)
return
}
setTestUrlError(null)
updateTestUrlMutation.mutate(trimmed)
}
return (
<SettingsLayout>
<Head title="Advanced Settings | Project N.O.M.A.D." />
<div className="xl:pl-72 w-full">
<main className="px-12 py-6">
<h1 className="text-4xl font-semibold mb-4">Advanced</h1>
<p className="text-text-muted mb-4">
Advanced configuration for operators. These settings are optional the defaults work
for most deployments.
</p>
<StyledSectionHeader title="Connectivity" className="mt-8 mb-4" />
<div className="bg-surface-primary rounded-lg border-2 border-border-subtle p-6">
<p className="text-sm text-text-secondary mb-4">
N.O.M.A.D. periodically checks whether it can reach the internet. By default it probes
Cloudflare's utility endpoint with a few fallbacks. Set a custom endpoint below if your
network blocks the defaults. Leave blank to use the built-in defaults.
</p>
{internetStatusTestUrlEnvOverride && (
<Alert
type="info"
variant="bordered"
title="Managed by environment variable"
message="The INTERNET_STATUS_TEST_URL environment variable is set and takes precedence over this setting. Remove it to manage the test URL here."
className="!mb-4"
/>
)}
<div className="flex items-end gap-3">
<div className="flex-1">
<Input
name="internetStatusTestUrl"
label="Internet Status Test URL"
helpText="A single http(s) URL used to check connectivity. Any HTTP response counts as online."
placeholder="https://1.1.1.1/cdn-cgi/trace"
value={internetStatusTestUrl}
disabled={internetStatusTestUrlEnvOverride}
error={Boolean(testUrlError)}
onChange={(e) => {
setInternetStatusTestUrl(e.target.value)
setTestUrlError(null)
}}
/>
{testUrlError && <p className="text-sm text-red-600 mt-1">{testUrlError}</p>}
</div>
<StyledButton
variant="primary"
onClick={handleSaveTestUrl}
loading={updateTestUrlMutation.isPending}
disabled={updateTestUrlMutation.isPending || internetStatusTestUrlEnvOverride}
className="mb-0.5"
>
Save
</StyledButton>
</div>
</div>
</main>
</div>
</SettingsLayout>
)
}

View File

@ -10,7 +10,6 @@ import StyledModal from '~/components/StyledModal'
import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'
import Alert from '~/components/Alert'
import { useNotifications } from '~/context/NotificationContext'
import ZimUploader from '~/components/ZimUploader'
import { ZimFileWithMetadata } from '../../../../types/zim'
import { SERVICE_NAMES } from '../../../../constants/service_names'
import { formatBytes } from '~/lib/util'
@ -26,11 +25,9 @@ export default function ZimPage() {
const { isInstalled } = useServiceInstalledStatus(SERVICE_NAMES.KIWIX)
const [sortKey, setSortKey] = useState<SortKey>('size')
const [sortDirection, setSortDirection] = useState<SortDirection>('desc')
const [showUploader, setShowUploader] = useState(false)
const { data, isLoading } = useQuery<ZimFileWithMetadata[]>({
queryKey: ['zim-files'],
queryFn: getFiles,
refetchOnWindowFocus: false,
})
async function getFiles() {
@ -138,50 +135,18 @@ export default function ZimPage() {
Manage your stored content files.
</p>
</div>
<div className="flex items-center gap-2">
{isInstalled && (
<StyledButton
variant="secondary"
icon={showUploader ? 'IconX' : 'IconUpload'}
onClick={() => setShowUploader((v) => !v)}
icon={'IconRefresh'}
loading={rescanMutation.isPending}
title="Rebuild the Kiwix library index from the files on disk. Use this after manually adding ZIM files outside of NOMAD."
onClick={() => rescanMutation.mutate()}
>
{showUploader ? 'Hide Uploader' : 'Upload ZIM File'}
Rescan Library
</StyledButton>
{isInstalled && (
<StyledButton
variant="secondary"
icon={'IconRefresh'}
loading={rescanMutation.isPending}
title="Rebuild the Kiwix library index from the files on disk. Use this after manually adding ZIM files outside of NOMAD."
onClick={() => rescanMutation.mutate()}
>
Rescan Library
</StyledButton>
)}
</div>
)}
</div>
{showUploader && (
<div className="mt-6">
<p className="text-text-muted text-sm mb-3">
Upload a ZIM file from your browser. Files up to 20 GB are supported. For best results upload from the same machine or over a stable LAN connection. Larger files should be copied directly to the storage volume.
</p>
<ZimUploader
existingFilenames={data?.map((f) => f.name) ?? []}
onUploadComplete={(added) => {
queryClient.invalidateQueries({ queryKey: ['zim-files'] })
queryClient.invalidateQueries({ queryKey: ['wikipedia-state'] })
queryClient.invalidateQueries({ queryKey: ['curated-categories'] })
setShowUploader(false)
addNotification({
type: 'success',
message:
added > 0
? `Upload complete. ${added} new ${added === 1 ? 'book' : 'books'} added to the library.`
: 'Upload complete. Library is up to date.',
})
}}
/>
</div>
)}
{!isInstalled && (
<Alert
title="The Kiwix application is not installed. Please install it to view downloaded ZIM files"

View File

@ -40,12 +40,10 @@ import type { CategoryWithStatus, SpecTier } from '../../../../types/collections
import useDownloads from '~/hooks/useDownloads'
import ActiveDownloads from '~/components/ActiveDownloads'
import { SERVICE_NAMES } from '../../../../constants/service_names'
import { ZimFileWithMetadata } from '../../../../types/zim'
const CURATED_CATEGORIES_KEY = 'curated-categories'
const WIKIPEDIA_STATE_KEY = 'wikipedia-state'
const CUSTOM_LIBRARIES_KEY = 'custom-libraries'
const ZIM_FILES_KEY = 'zim-files'
type CustomLibrary = { id: number; name: string; base_url: string; is_default: boolean }
type BrowseResult = {
@ -106,15 +104,6 @@ export default function ZimRemoteExplorer() {
refetchOnWindowFocus: false,
})
const { data: localFiles } = useQuery<ZimFileWithMetadata[]>({
queryKey: [ZIM_FILES_KEY],
queryFn: async () => {
const res = await api.listZimFiles()
return res.data.files
},
refetchOnWindowFocus: false,
})
const { data: downloads, invalidate: invalidateDownloads } = useDownloads({
filetype: 'zim',
enabled: true,
@ -163,16 +152,15 @@ export default function ZimRemoteExplorer() {
const flatData = useMemo(() => {
const mapped = data?.pages.flatMap((page) => page.items) || []
const localNames = new Set(localFiles?.map((f) => f.name) ?? [])
// remove items that are currently downloading
return mapped.filter((item) => {
const isDownloading = downloads?.some((download) => {
const filename = item.download_url.split('/').pop()
return filename && download.filepath.endsWith(filename)
})
const isPresent = localNames.has(item.file_name)
return !isDownloading && !isPresent
return !isDownloading
})
}, [data, downloads, localFiles])
}, [data, downloads])
const hasMore = useMemo(() => data?.pages[data.pages.length - 1]?.has_more || false, [data])
const fetchOnBottomReached = useCallback(

View File

@ -2,7 +2,6 @@ import { Head, router } from '@inertiajs/react'
import { useEffect, useRef, useState } from 'react'
import {
IconAlertTriangle,
IconArrowRight,
IconArrowUp,
IconBook,
IconBox,
@ -45,7 +44,6 @@ import { getServiceLink } from '~/lib/navigation'
import { getSupplyDepotDocLink } from '../../constants/supply_depot_docs'
import api from '~/lib/api'
import { toTitleCase } from '../../app/utils/misc'
import { SERVICE_NAMES } from '../../constants/service_names'
function extractTag(containerImage: string): string {
if (!containerImage) return ''
@ -84,7 +82,6 @@ type Modal =
| { type: 'restart'; service: ServiceSlim }
| { type: 'reinstall'; service: ServiceSlim }
| { type: 'delete'; service: ServiceSlim }
| { type: 'uninstall'; service: ServiceSlim }
| { type: 'logs'; service: ServiceSlim }
| { type: 'stats'; service: ServiceSlim }
| { type: 'update'; service: ServiceSlim }
@ -170,7 +167,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
.then((res) => {
if (res) setPreflight(res)
})
.catch(() => { }) // non-fatal; proceed without warnings
.catch(() => {}) // non-fatal; proceed without warnings
.finally(() => setPreflightLoading(false))
}, [modal])
@ -195,12 +192,6 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
const installedServices = filteredServices.filter((s) => s.installed)
const availableServices = filteredServices.filter((s) => !s.installed)
// Whether the new Kolibri (Gen 2) install exists — gates the "Migrate content to Gen 2" action on
// the legacy Kolibri card. Computed from the full (unfiltered) list so a search filter can't hide it.
const educationGen2Installed = props.system.services.some(
(s) => s.service_name === SERVICE_NAMES.KOLIBRI_GEN2 && s.installed
)
// ── Actions ───────────────────────────────────────────────────────────────
async function handleInstall(service: ServiceSlim) {
const hasWarnings =
@ -208,61 +199,38 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
if (hasWarnings && !forceInstall) return
// Keep the modal open with a spinning confirm button while the request is in
// flight; close it once the install job is dispatched. Progress then streams
// via the InstallActivityFeed broadcast, so we drop the loading flag here.
setLoading(true)
const result = await api.installService(service.service_name)
setModal(null)
const result = await api.installService(service.service_name)
setLoading(false)
if (!result?.success) showError(result?.message || 'Failed to start installation.')
}
async function handleAffect(service: ServiceSlim, action: 'start' | 'stop' | 'restart') {
setModal(null)
setLoading(true)
const result = await api.affectService(service.service_name, action)
setModal(null)
if (!result?.success) {
setLoading(false)
showError(result?.message || `Failed to ${action} service.`)
} else {
// Keep loading=true so the overlay covers the page until it reloads.
setTimeout(() => window.location.reload(), 1500)
}
setLoading(false)
if (!result?.success) showError(result?.message || `Failed to ${action} service.`)
else setTimeout(() => window.location.reload(), 1500)
}
async function handleForceReinstall(service: ServiceSlim) {
setModal(null)
setLoading(true)
const result = await api.forceReinstallService(service.service_name)
setModal(null)
setLoading(false)
if (!result?.success) showError(result?.message || 'Failed to start reinstall.')
}
async function handleDelete(service: ServiceSlim) {
setModal(null)
setLoading(true)
const result = await api.deleteCustomApp(service.service_name, removeImage)
setRemoveImage(false)
setModal(null)
if (!result?.success) {
setLoading(false)
showError(result?.message || 'Failed to delete app.')
} else {
setTimeout(() => window.location.reload(), 1000)
}
}
async function handleUninstall(service: ServiceSlim) {
setLoading(true)
const result = await api.uninstallService(service.service_name, removeImage)
setRemoveImage(false)
setModal(null)
if (!result?.success) {
setLoading(false)
showError(result?.message || 'Failed to uninstall app.')
} else {
setTimeout(() => window.location.reload(), 1000)
}
setLoading(false)
if (!result?.success) showError(result?.message || 'Failed to delete app.')
else setTimeout(() => window.location.reload(), 1000)
}
async function handleUpdate(service: ServiceSlim) {
@ -360,19 +328,9 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
<AppLayout>
<Head title="Supply Depot" />
{loading && !modal && <LoadingSpinner fullscreen text="Working..." />}
{loading && <LoadingSpinner fullscreen text="Working..." />}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{!isOnline && (
<Alert
title="No internet connection. You may not be able to download files."
message=""
type="warning"
variant="solid"
className="!mb-4"
/>
)}
{/* ── Hero / controls panel ─────────────────────────────────────────── */}
<div className="rounded-lg overflow-hidden bg-desert-white border border-desert-stone-light shadow-sm mb-8">
{/* Green header band */}
@ -393,6 +351,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
<div className="absolute top-0 right-0 w-24 h-24 transform translate-x-8 -translate-y-8">
<div className="w-full h-full bg-desert-green-dark opacity-30 transform rotate-45" />
</div>
<div className="relative flex items-center gap-3">
<IconBox className="text-white opacity-90 flex-shrink-0" size={28} />
<div>
@ -450,10 +409,11 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
<button
key={cat.id}
onClick={() => setActiveCategory(cat.id)}
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors cursor-pointer border ${activeCategory === cat.id
? 'bg-desert-green text-white border-desert-green'
: 'bg-surface-secondary text-text-muted border-desert-stone-lighter hover:text-text-primary hover:border-desert-stone-light'
}`}
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors cursor-pointer border ${
activeCategory === cat.id
? 'bg-desert-green text-white border-desert-green'
: 'bg-surface-secondary text-text-muted border-desert-stone-lighter hover:text-text-primary hover:border-desert-stone-light'
}`}
>
{cat.label}
</button>
@ -490,7 +450,6 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
onRestart={() => setModal({ type: 'restart', service })}
onReinstall={() => setModal({ type: 'reinstall', service })}
onDelete={() => setModal({ type: 'delete', service })}
onUninstall={() => setModal({ type: 'uninstall', service })}
onLogs={() => setModal({ type: 'logs', service })}
onStats={() => setModal({ type: 'stats', service })}
onEdit={() => handleEdit(service)}
@ -502,8 +461,6 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
}
autoUpdateMasterEnabled={appAutoUpdateMasterEnabled}
onToggleAutoUpdate={(enabled) => handleToggleAutoUpdate(service, enabled)}
migrationInstructionsHref={(service.service_name.startsWith(SERVICE_NAMES.KOLIBRI) && educationGen2Installed) ? getSupplyDepotDocLink(SERVICE_NAMES.KOLIBRI) || undefined : undefined}
migrationInstructionsText={(service.service_name === SERVICE_NAMES.KOLIBRI) ? 'How to migrate content to Gen 2' : "How to migrate content from Gen 1"}
/>
))}
</div>
@ -527,7 +484,6 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
onRestart={() => setModal({ type: 'restart', service })}
onReinstall={() => setModal({ type: 'reinstall', service })}
onDelete={() => setModal({ type: 'delete', service })}
onUninstall={() => setModal({ type: 'uninstall', service })}
onLogs={() => setModal({ type: 'logs', service })}
onStats={() => setModal({ type: 'stats', service })}
onEdit={() => handleEdit(service)}
@ -550,10 +506,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
<StyledModal
title={`Install ${modal.service.friendly_name ?? modal.service.service_name}`}
open
onCancel={() => {
if (loading) return
setModal(null)
}}
onCancel={() => setModal(null)}
onConfirm={() => handleInstall(modal.service)}
confirmText="Install"
confirmIcon="IconDownload"
@ -611,10 +564,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
<StyledModal
title={`Start ${modal.service.friendly_name ?? modal.service.service_name}`}
open
onCancel={() => {
if (loading) return
setModal(null)
}}
onCancel={() => setModal(null)}
onConfirm={() => handleAffect(modal.service, 'start')}
confirmText="Start"
confirmIcon="IconPlayerPlay"
@ -630,10 +580,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
<StyledModal
title={`Stop ${modal.service.friendly_name ?? modal.service.service_name}`}
open
onCancel={() => {
if (loading) return
setModal(null)
}}
onCancel={() => setModal(null)}
onConfirm={() => handleAffect(modal.service, 'stop')}
confirmText="Stop"
confirmIcon="IconPlayerStop"
@ -649,10 +596,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
<StyledModal
title={`Restart ${modal.service.friendly_name ?? modal.service.service_name}`}
open
onCancel={() => {
if (loading) return
setModal(null)
}}
onCancel={() => setModal(null)}
onConfirm={() => handleAffect(modal.service, 'restart')}
confirmText="Restart"
confirmIcon="IconRefresh"
@ -668,10 +612,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
<StyledModal
title={`Force Reinstall ${modal.service.friendly_name ?? modal.service.service_name}`}
open
onCancel={() => {
if (loading) return
setModal(null)
}}
onCancel={() => setModal(null)}
onConfirm={() => handleForceReinstall(modal.service)}
confirmText="Wipe & Reinstall"
confirmIcon="IconRefresh"
@ -692,7 +633,6 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
title={`Delete ${modal.service.friendly_name ?? modal.service.service_name}`}
open
onCancel={() => {
if (loading) return
setRemoveImage(false)
setModal(null)
}}
@ -719,39 +659,6 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
</StyledModal>
)}
{/* Uninstall curated app modal */}
{modal?.type === 'uninstall' && (
<StyledModal
title={`Uninstall ${modal.service.friendly_name ?? modal.service.service_name}`}
open
onCancel={() => {
if (loading) return
setRemoveImage(false)
setModal(null)
}}
onConfirm={() => handleUninstall(modal.service)}
confirmText="Uninstall"
confirmIcon="IconTrash"
confirmVariant="danger"
confirmLoading={loading}
icon={<IconAlertTriangle className="text-desert-red" size={40} />}
>
<div className="space-y-3 text-sm text-text-muted">
<p className="font-semibold text-desert-red">This will remove the app from this device.</p>
<p>The container will be stopped and removed, and the app returns to the catalog below. App data under the storage folder stays on disk, so reinstalling brings it back as it was.</p>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={removeImage}
onChange={(e) => setRemoveImage(e.target.checked)}
className="accent-desert-red h-4 w-4 rounded"
/>
<span className="text-text-muted text-xs">Also remove the Docker image to reclaim disk space</span>
</label>
</div>
</StyledModal>
)}
{/* Logs modal */}
{modal?.type === 'logs' && (
<ServiceLogsModal
@ -778,10 +685,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
record={modal.service}
currentTag={extractTag(modal.service.container_image)}
latestVersion={modal.service.available_update_version!}
onCancel={() => {
if (loading) return
setModal(null)
}}
onCancel={() => setModal(null)}
onUpdate={(targetVersion) => {
const service = modal.service
setModal(null)
@ -834,7 +738,6 @@ interface AppCardProps {
onRestart: () => void
onReinstall: () => void
onDelete: () => void
onUninstall: () => void
onLogs: () => void
onStats: () => void
onEdit: () => void
@ -846,8 +749,6 @@ interface AppCardProps {
// Global master switch (Settings → Updates). When off, per-app toggles are inert.
autoUpdateMasterEnabled?: boolean
onToggleAutoUpdate?: (enabled: boolean) => void
migrationInstructionsHref?: string
migrationInstructionsText?: string
}
function AppCard({
@ -861,7 +762,6 @@ function AppCard({
onRestart,
onReinstall,
onDelete,
onUninstall,
onLogs,
onStats,
onEdit,
@ -871,8 +771,6 @@ function AppCard({
autoUpdateEnabled,
autoUpdateMasterEnabled,
onToggleAutoUpdate,
migrationInstructionsHref,
migrationInstructionsText,
}: AppCardProps) {
const isRunning = service.status === 'running'
const isStopped = service.installed && !isRunning
@ -907,10 +805,11 @@ function AppCard({
return (
<div
className={`relative flex flex-col rounded-xl border p-4 bg-surface-primary shadow-sm transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 ${service.installed
? 'border-desert-stone-light'
: 'border-desert-stone-lighter hover:border-desert-stone-light'
}`}
className={`relative flex flex-col rounded-xl border p-4 bg-surface-primary shadow-sm transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 ${
service.installed
? 'border-desert-stone-light'
: 'border-desert-stone-lighter hover:border-desert-stone-light'
}`}
>
{/* Installed accent spine (rounded to follow the card corners the card no longer clips
overflow so the Manage dropdown can open above the card without being cut off) */}
@ -984,14 +883,6 @@ function AppCard({
modified
</span>
) : null}
{service.is_deprecated ? (
<span
className="text-xs px-2 py-0.5 rounded-full font-medium bg-desert-orange-lighter text-desert-orange-dark border border-desert-orange-light"
title="This is a legacy version that's no longer maintained. Install the current Education Platform from the catalog, then uninstall this one."
>
legacy
</span>
) : null}
{uiPort && (
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-secondary text-text-muted font-mono">
{uiIsHttps ? '🔒 ' : ''}:{uiPort}
@ -1029,7 +920,7 @@ function AppCard({
{/* Open button — shown when the app has a default location or a user-set custom URL */}
{(service.ui_location || service.custom_url) && (
<a
href={getServiceLink(service.ui_location || "", service.custom_url)}
href={getServiceLink(service.ui_location, service.custom_url)}
target="_blank"
rel="noopener noreferrer"
className="flex-1"
@ -1071,20 +962,6 @@ function AppCard({
<DropdownItem icon={<IconChartBar className="h-4 w-4" />} label="Stats" onClick={onStats} />
<DropdownItem icon={<IconPencil className="h-4 w-4" />} label="Edit" onClick={onEdit} />
<DropdownItem icon={<IconWorld className="h-4 w-4" />} label="Set custom URL" onClick={onSetUrl} />
{
migrationInstructionsHref ? (
<a
href={migrationInstructionsHref}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-2 w-full px-3 py-2 text-xs transition-colors text-left cursor-pointer text-text-primary hover:bg-surface-secondary"
>
<IconBook className="h-4 w-4" />
{migrationInstructionsText || 'Migration instructions'}
</a>
) : (null)
}
{!service.is_custom && onToggleAutoUpdate ? (
autoUpdateMasterEnabled ? (
<DropdownItem
@ -1117,9 +994,7 @@ function AppCard({
<DropdownItem icon={<IconRefresh className="h-4 w-4 text-desert-orange" />} label="Force Reinstall" onClick={onReinstall} danger />
{service.is_custom ? (
<DropdownItem icon={<IconTrash className="h-4 w-4 text-desert-red" />} label="Delete" onClick={onDelete} danger />
) : (
<DropdownItem icon={<IconTrash className="h-4 w-4 text-desert-red" />} label="Uninstall" onClick={onUninstall} danger />
)}
): null}
</div>
)}
</div>
@ -1155,10 +1030,11 @@ function DropdownItem({
e.stopPropagation()
onClick()
}}
className={`flex items-center gap-2 w-full px-3 py-2 text-xs transition-colors text-left cursor-pointer ${danger
? 'text-desert-red hover:bg-desert-red/10'
: 'text-text-primary hover:bg-surface-secondary'
}`}
className={`flex items-center gap-2 w-full px-3 py-2 text-xs transition-colors text-left cursor-pointer ${
danger
? 'text-desert-red hover:bg-desert-red/10'
: 'text-text-primary hover:bg-surface-secondary'
}`}
>
{icon}
{label}

102
admin/package-lock.json generated
View File

@ -35,7 +35,6 @@
"@uppy/core": "5.2.0",
"@uppy/dashboard": "5.1.0",
"@uppy/react": "5.1.1",
"@uppy/xhr-upload": "5.2.0",
"@vinejs/vine": "3.0.1",
"@vitejs/plugin-react": "4.7.0",
"autoprefixer": "10.5.0",
@ -48,12 +47,11 @@
"edge.js": "6.4.0",
"fast-xml-parser": "5.7.0",
"fuse.js": "7.1.0",
"ioredis": "5.10.1",
"ipaddr.js": "2.4.0",
"jszip": "3.10.1",
"luxon": "3.7.2",
"maplibre-gl": "4.7.1",
"mysql2": "3.22.5",
"mysql2": "3.16.2",
"ollama": "0.6.3",
"openai": "6.38.0",
"pdf-parse": "2.4.5",
@ -5685,12 +5683,6 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/retry": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz",
"integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==",
"license": "MIT"
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
@ -6037,20 +6029,6 @@
"integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==",
"license": "ISC"
},
"node_modules/@uppy/companion-client": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@uppy/companion-client/-/companion-client-5.1.1.tgz",
"integrity": "sha512-DzrOWTbIZHvtgAFXBMYHk2wD27NjpBSVhY2tEiEIUhPd2CxbFRZjHM/N3HOt3VwZEAP471QWFLlJRWPcIY3A2Q==",
"license": "MIT",
"dependencies": {
"@uppy/utils": "^7.1.1",
"namespace-emitter": "^2.0.1",
"p-retry": "^6.1.0"
},
"peerDependencies": {
"@uppy/core": "^5.1.1"
}
},
"node_modules/@uppy/components": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@uppy/components/-/components-1.2.0.tgz",
@ -6196,19 +6174,6 @@
"preact": "^10.26.10"
}
},
"node_modules/@uppy/xhr-upload": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@uppy/xhr-upload/-/xhr-upload-5.2.0.tgz",
"integrity": "sha512-3LV/X5Of6BINnKplP+CwUJ0a4/7cRFfzxwGyXnW+uCrNQHoo09dttcz3begWHejGvzenQHuUnMO3Fxyc71Pryg==",
"license": "MIT",
"dependencies": {
"@uppy/companion-client": "^5.1.1",
"@uppy/utils": "^7.2.0"
},
"peerDependencies": {
"@uppy/core": "^5.2.0"
}
},
"node_modules/@vavite/multibuild": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@vavite/multibuild/-/multibuild-5.1.0.tgz",
@ -10548,18 +10513,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-network-error": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.1.tgz",
"integrity": "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@ -12856,9 +12809,9 @@
}
},
"node_modules/mysql2": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.5.tgz",
"integrity": "sha512-95uZ2TrPWAZdwpB3vvvDbmEMcNG8yIeNCyu6GUcr/QnWEE/wXm7+mhOCsdQfWQDTV7qYT/PDUZ4U4UPP4AsXqQ==",
"version": "3.16.2",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.16.2.tgz",
"integrity": "sha512-JsqBpYNy7pH20lGfPuSyRSIcCxSeAIwxWADpV64nP9KeyN3ZKpHZgjKXuBKsh7dH6FbOvf1bOgoVKjSUPXRMTw==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.2",
@ -12866,15 +12819,13 @@
"generate-function": "^2.3.1",
"iconv-lite": "^0.7.2",
"long": "^5.3.2",
"lru.min": "^1.1.4",
"lru.min": "^1.1.3",
"named-placeholders": "^1.1.6",
"sql-escaper": "^1.3.3"
"seq-queue": "^0.0.5",
"sqlstring": "^2.3.3"
},
"engines": {
"node": ">= 8.0"
},
"peerDependencies": {
"@types/node": ">= 8"
}
},
"node_modules/mysql2/node_modules/iconv-lite": {
@ -13392,23 +13343,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-retry": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz",
"integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==",
"license": "MIT",
"dependencies": {
"@types/retry": "0.12.2",
"is-network-error": "^1.0.0",
"retry": "^0.13.1"
},
"engines": {
"node": ">=16.17"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-timeout": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz",
@ -14892,6 +14826,7 @@
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
"integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">= 4"
@ -15096,6 +15031,11 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/seq-queue": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
},
"node_modules/serialize-error": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-12.0.0.tgz",
@ -15641,19 +15581,13 @@
"node": ">= 10.x"
}
},
"node_modules/sql-escaper": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
"integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==",
"node_modules/sqlstring": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=2.0.0",
"node": ">=12.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
"node": ">= 0.6"
}
},
"node_modules/ssh2": {

View File

@ -88,7 +88,6 @@
"@uppy/core": "5.2.0",
"@uppy/dashboard": "5.1.0",
"@uppy/react": "5.1.1",
"@uppy/xhr-upload": "5.2.0",
"@vinejs/vine": "3.0.1",
"@vitejs/plugin-react": "4.7.0",
"autoprefixer": "10.5.0",
@ -101,12 +100,11 @@
"edge.js": "6.4.0",
"fast-xml-parser": "5.7.0",
"fuse.js": "7.1.0",
"ioredis": "5.10.1",
"ipaddr.js": "2.4.0",
"jszip": "3.10.1",
"luxon": "3.7.2",
"maplibre-gl": "4.7.1",
"mysql2": "3.22.5",
"mysql2": "3.16.2",
"ollama": "0.6.3",
"openai": "6.38.0",
"pdf-parse": "2.4.5",

View File

@ -33,7 +33,6 @@ MEMORY_CAP = "2g" # generous cap; some apps (Stirling) OOM under 1g and fal
KNOWN_NEEDS_SETUP = {
"nomad_kiwix_server": "needs a ZIM library (managed separately by NOMAD)",
"nomad_meshtasticd": "needs a config.yaml with a MAC address",
"nomad_meshcore_web": "serves HTTPS on 443 only with the bind-mounted SSL config (absent in a bare probe)",
}

View File

@ -57,7 +57,6 @@ router
router.get('/zim/remote-explorer', [SettingsController, 'zimRemote'])
router.get('/benchmark', [SettingsController, 'benchmark'])
router.get('/support', [SettingsController, 'support'])
router.get('/advanced', [SettingsController, 'advanced'])
})
.prefix('/settings')
@ -148,12 +147,9 @@ router
router.get('/file-warnings', [RagController, 'getFileWarnings'])
router.delete('/files', [RagController, 'deleteFile'])
router.post('/files/embed', [RagController, 'embedFile'])
router.get('/files/content', [RagController, 'getFileContent'])
router.get('/files/download', [RagController, 'downloadFile'])
router.get('/active-jobs', [RagController, 'getActiveJobs'])
router.get('/failed-jobs', [RagController, 'getFailedJobs'])
router.delete('/failed-jobs', [RagController, 'cleanupFailedJobs'])
router.delete('/jobs', [RagController, 'cancelAllJobs'])
router.get('/job-status', [RagController, 'getJobStatus'])
router.post('/sync', [RagController, 'scanAndSync'])
router.post('/re-embed-all', [RagController, 'reembedAll'])
@ -173,7 +169,6 @@ router
router.post('/services/affect', [SystemController, 'affectService'])
router.post('/services/install', [SystemController, 'installService'])
router.post('/services/force-reinstall', [SystemController, 'forceReinstallService'])
router.post('/services/uninstall', [SystemController, 'uninstallService'])
router.post('/services/check-updates', [SystemController, 'checkServiceUpdates'])
router.get('/services/preflight', [SystemController, 'preflightCheck'])
router.get('/services/suggest-port', [SystemController, 'suggestCustomPort'])
@ -210,7 +205,6 @@ router
router.post('/download-remote', [ZimController, 'downloadRemote'])
router.post('/download-category-tier', [ZimController, 'downloadCategoryTier'])
router.post('/upload', [ZimController, 'upload'])
router.get('/wikipedia', [ZimController, 'getWikipediaState'])
router.post('/wikipedia/select', [ZimController, 'selectWikipedia'])

View File

@ -11,19 +11,9 @@ import type { StoredFileInfo } from '../../types/rag.js'
/** Wrap source paths into the minimal StoredFileInfo shape that
* `groupAndSortKbFiles` now expects. State + chunk count are irrelevant to
* grouping/sorting behavior; the per-file state-pill rendering is exercised
* separately in the modal's component tests (added in the follow-up PR).
* Metadata fields default to nullish/false individual tests that exercise
* sort-by-size or sort-by-uploadedAt override as needed. */
* separately in the modal's component tests (added in the follow-up PR). */
const asInfos = (sources: string[]): StoredFileInfo[] =>
sources.map((source) => ({
source,
state: null,
chunksEmbedded: 0,
fileName: sourceToDisplayName(source),
size: null,
uploadedAt: null,
isUserUpload: classifyKbFile(source) === 'upload',
}))
sources.map((source) => ({ source, state: null, chunksEmbedded: 0 }))
test('classifyKbFile distinguishes ZIM, upload, admin_docs, and other', () => {
assert.equal(
@ -116,78 +106,3 @@ test('groupAndSortKbFiles preserves a stable synthetic key for the admin docs gr
// can be used as a React key without colliding with any real file row.
assert.equal(groups[0].source, '__admin_docs_group__')
})
/** Sized fixtures for the sort tests. `size` and `uploadedAt` are set so the
* three sort keys (name, size, uploadedAt) produce visibly different orders
* if a test passed for name it had better fail for size. */
const sized: StoredFileInfo[] = [
{ source: '/app/storage/kb_uploads/charlie.txt', state: null, chunksEmbedded: 0, fileName: 'charlie.txt', size: 100, uploadedAt: '2026-01-01T00:00:00Z', isUserUpload: true },
{ source: '/app/storage/kb_uploads/alpha.txt', state: null, chunksEmbedded: 0, fileName: 'alpha.txt', size: 300, uploadedAt: '2026-03-01T00:00:00Z', isUserUpload: true },
{ source: '/app/storage/kb_uploads/bravo.txt', state: null, chunksEmbedded: 0, fileName: 'bravo.txt', size: 200, uploadedAt: '2026-02-01T00:00:00Z', isUserUpload: true },
]
test('groupAndSortKbFiles sorts by size ascending', () => {
const groups = groupAndSortKbFiles(sized, { key: 'size', direction: 'asc' })
assert.deepEqual(groups.map((g) => g.displayName), ['charlie.txt', 'bravo.txt', 'alpha.txt'])
})
test('groupAndSortKbFiles sorts by size descending', () => {
const groups = groupAndSortKbFiles(sized, { key: 'size', direction: 'desc' })
assert.deepEqual(groups.map((g) => g.displayName), ['alpha.txt', 'bravo.txt', 'charlie.txt'])
})
test('groupAndSortKbFiles sorts by uploadedAt ascending', () => {
const groups = groupAndSortKbFiles(sized, { key: 'uploadedAt', direction: 'asc' })
assert.deepEqual(groups.map((g) => g.displayName), ['charlie.txt', 'bravo.txt', 'alpha.txt'])
})
test('groupAndSortKbFiles sorts by uploadedAt descending', () => {
const groups = groupAndSortKbFiles(sized, { key: 'uploadedAt', direction: 'desc' })
assert.deepEqual(groups.map((g) => g.displayName), ['alpha.txt', 'bravo.txt', 'charlie.txt'])
})
test('groupAndSortKbFiles parks files with null size at the end of size sort', () => {
const withMissing: StoredFileInfo[] = [
...sized,
{ source: '/app/storage/kb_uploads/zzz_missing.txt', state: null, chunksEmbedded: 0, fileName: 'zzz_missing.txt', size: null, uploadedAt: null, isUserUpload: true },
]
// Missing-size files sort last regardless of direction so the "real" data
// owns the top of the view either way.
const asc = groupAndSortKbFiles(withMissing, { key: 'size', direction: 'asc' })
assert.equal(asc.at(-1)?.displayName, 'zzz_missing.txt')
const desc = groupAndSortKbFiles(withMissing, { key: 'size', direction: 'desc' })
assert.equal(desc.at(-1)?.displayName, 'zzz_missing.txt')
})
test('groupAndSortKbFiles preserves bucket order across all sort modes', () => {
const mixed: StoredFileInfo[] = [
{ source: '/app/storage/zim/big.zim', state: null, chunksEmbedded: 0, fileName: 'big.zim', size: 999, uploadedAt: '2026-01-01T00:00:00Z', isUserUpload: false },
{ source: '/app/storage/kb_uploads/small.txt', state: null, chunksEmbedded: 0, fileName: 'small.txt', size: 1, uploadedAt: '2026-09-01T00:00:00Z', isUserUpload: true },
{ source: '/app/docs/release-notes.md', state: null, chunksEmbedded: 0, fileName: 'release-notes.md', size: 50, uploadedAt: '2026-05-01T00:00:00Z', isUserUpload: false },
]
// Even if size-desc would put zim first naturally, sort runs *within* a
// bucket — buckets themselves stay in the canonical zim → upload → admin_docs
// order. This is the invariant that lets the per-bucket grouping stay
// legible while still giving the user a sortable view.
for (const direction of ['asc', 'desc'] as const) {
for (const key of ['name', 'size', 'uploadedAt'] as const) {
const groups = groupAndSortKbFiles(mixed, { key, direction })
assert.deepEqual(
groups.map((g) => g.bucket),
['zim', 'upload', 'admin_docs'],
`bucket order changed for ${key}/${direction}`
)
}
}
})
test('groupAndSortKbFiles emits null metadata + isUserUpload=false for the admin_docs group', () => {
const groups = groupAndSortKbFiles(asInfos([
'/app/docs/release-notes.md',
'/app/README.md',
]))
assert.equal(groups[0].bucket, 'admin_docs')
assert.equal(groups[0].size, null)
assert.equal(groups[0].uploadedAt, null)
assert.equal(groups[0].isUserUpload, false)
})

View File

@ -7,7 +7,6 @@ export const KV_STORE_SCHEMA = {
'system.updateAvailable': 'boolean',
'system.latestVersion': 'string',
'system.earlyAccess': 'boolean',
'system.internetStatusTestUrl': 'string',
'autoUpdate.enabled': 'boolean',
'autoUpdate.windowStart': 'string',
'autoUpdate.windowEnd': 'string',

View File

@ -56,15 +56,6 @@ export type StoredFileInfo = {
source: string
state: import('./kb_ingest_state.js').KbIngestStateValue | null
chunksEmbedded: number
/** Filename portion of `source` (last path segment). */
fileName: string
/** File size in bytes from disk; null if the file is missing or unreadable. */
size: number | null
/** Last-modified timestamp from disk (ISO 8601); null if unavailable. */
uploadedAt: string | null
/** True when `source` lives under the user-uploads directory. Drives which
* rows offer view/download in the UI. */
isUserUpload: boolean
}
/**

View File

@ -18,6 +18,5 @@ export type ServiceSlim = Pick<
| 'auto_update_enabled'
| 'is_custom'
| 'is_user_modified'
| 'is_deprecated'
| 'category'
> & { status?: string }

View File

@ -7,29 +7,6 @@ echo "Starting entrypoint script..."
# Ensure required storage directories exist (volume may be freshly mounted)
mkdir -p /app/storage/logs /app/storage/kb_uploads
# Wait for Redis to be reachable before booting anything that opens a BullMQ
# connection. `depends_on: condition: service_healthy` only gates a clean
# `up --recreate`; it is NOT re-checked on `docker compose restart` or a
# `restart: unless-stopped` bounce, so without this the app can race Docker's
# DNS (EAI_AGAIN) or dial a restarted Redis container's stale IP (ECONNREFUSED).
# This isn't load-bearing since legacy installs used a mounted entrypoint script
# that may override this script, but it's a cost-nothing check for newer installs.
# The real check is done by the application itself, but this provides a safety net.
REDIS_HOST="${REDIS_HOST:-redis}"
REDIS_PORT="${REDIS_PORT:-6379}"
echo "Waiting for Redis at ${REDIS_HOST}:${REDIS_PORT}..."
for i in $(seq 1 60); do
if node -e "const net=require('net');const s=net.connect(Number(process.env.REDIS_PORT||6379),process.env.REDIS_HOST||'redis');s.on('connect',()=>{s.end();process.exit(0)});s.on('error',()=>process.exit(1));" 2>/dev/null; then
echo "Redis is up and running!"
break
fi
if [ "$i" -eq 60 ]; then
echo "Timed out waiting for Redis at ${REDIS_HOST}:${REDIS_PORT}" >&2
exit 1
fi
sleep 1
done
# Run AdonisJS migrations
echo "Running AdonisJS migrations..."
node ace migration:run --force

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "project-nomad",
"version": "1.33.0-rc.1",
"version": "1.32.0-rc.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "project-nomad",
"version": "1.33.0-rc.1",
"version": "1.32.0-rc.6",
"license": "Apache-2.0"
}
}

View File

@ -1,6 +1,6 @@
{
"name": "project-nomad",
"version": "1.33.0",
"version": "1.39.0",
"description": "Project N.O.M.A.D. (Node for Offline Media, Archives, and Data) - an offline-first knowledge and education server.",
"main": "index.js",
"scripts": {