Compare commits

...

83 Commits

Author SHA1 Message Date
cosmistack-bot 6a4f02dd46 docs(release): finalize v1.33.0 release notes [skip ci] 2026-06-23 17:52:28 +00:00
cosmistack-bot cf294bf908 chore(release): 1.33.0 [skip ci] 2026-06-23 17:51:38 +00:00
jakeaturner c794d5e4b0
Revert "chore(release): 1.33.0 [skip ci]"
This reverts commit d8cca183f9.
2026-06-23 17:49:54 +00:00
jakeaturner 587600ba78
Revert "docs(release): finalize v1.33.0 release notes [skip ci]"
This reverts commit c9a86790de.
2026-06-23 17:49:36 +00:00
jakeaturner e82a2934c7
Revert "chore(release): 1.33.1 [skip ci]"
This reverts commit 0ee5dda534.
2026-06-23 17:49:17 +00:00
cosmistack-bot 0ee5dda534 chore(release): 1.33.1 [skip ci] 2026-06-23 17:41:25 +00:00
jakeaturner f4af81ef02
fix: lazy-connect + retry for ioredis to avoid blocked startup 2026-06-23 17:40:10 +00:00
cosmistack-bot c9a86790de docs(release): finalize v1.33.0 release notes [skip ci] 2026-06-23 04:49:49 +00:00
cosmistack-bot d8cca183f9 chore(release): 1.33.0 [skip ci] 2026-06-23 04:48:59 +00:00
jakeaturner 76ce725340
chore(release): set version to 1.33.0-rc.1 2026-06-23 04:47:13 +00:00
jakeaturner 9609edc281
chore(docs): update release notes 2026-06-23 04:47:12 +00:00
Jake Turner 0143afe7cc
fix(supply-depot): bump default Ollama and CyberChef image versions (#1036) 2026-06-23 04:47:12 +00:00
Jake Turner 5af27e9904
fix(supply-depot): ensure all curated images pinned to specific versions (#1033) 2026-06-23 04:47:12 +00:00
Jake Turner 88ac4d5ec4
feat(RAG): adds the ability to cancel all embedding jobs (#1034) 2026-06-23 04:47:11 +00:00
jakeaturner f0142b67f8
chore(docs): update release notes 2026-06-23 04:47:11 +00:00
jakeaturner 4a795df793
feat: configurable internet test url override in new Advanced Settings page 2026-06-23 04:47:10 +00:00
Jake Turner 02c9f72bf0
fix(UI): unifies Supply Depot icon and improves loading UX (#1022) 2026-06-23 04:47:10 +00:00
Benjamin Smith 5181637926
feat(KnowledgeBase): add document viewer, download, metadata, and sorting (#721)
Rebuilt on top of dev's RFC #883 state-machine UI rather than the now-defunct
StoredFile shape:

- Extend StoredFileInfo with fileName/size/uploadedAt/isUserUpload
- Populate metadata from on-disk stats in RagService.getStoredFiles
- Add fileSourceSchema validator + getFileContent/downloadFile endpoints
  scoped to the uploads directory only (tighter than the original PR — matches
  docs_service traversal pattern)
- KnowledgeBaseModal: sortable Size and Uploaded columns; View/Download
  buttons on upload-bucket rows; new FileViewerModal for in-browser text
  preview. Bucket grouping preserved — sort applies within each bucket.
- Use formatBytes from ~/lib/util rather than redefining
2026-06-23 04:47:09 +00:00
jakeaturner fad9e30ddc
fix(zim): stabilize uploader state to avoid cancel on tab refocus
Also improves surfacing of upload error messages
2026-06-23 04:47:09 +00:00
Henry Estela a7d05859d4
feat(zim): add zim uploader in content manager
adds a collapsible file uploader to accept zim file uploads into kiwix.
2026-06-23 04:47:08 +00:00
Lorenzo Galassi fe6735fdcb
fix(queue): share one ioredis connection across BullMQ queues and workers (#1009)
BullMQ instantiates a fresh ioredis client per Queue/Worker when handed a
plain {host, port} config object, and under sustained ZIM ingestion the
embed pipeline leaked ~1 client/sec until Redis maxclients was exhausted.
Pass a single shared ioredis instance (maxRetriesPerRequest: null, as
required by BullMQ) so all queues and workers reuse one client pool.
Workers still duplicate the connection once for their blocking client,
which is expected and bounded.

Closes #885
2026-06-23 04:47:08 +00:00
Jake Turner b507b8bd4e
chore(deps): bump mysql2 in admin (#1021) 2026-06-23 04:47:07 +00:00
Jake Turner 8982d93a31
feat: replace legacy Kolibri image default with latest v19 image (#1019)
* feat: replace legacy Kolibri image default with latest v19 image
* feat(supply-depot): add content migration instructions for Edu Platform Gen 1 to 2
2026-06-23 04:47:07 +00:00
jakeaturner 1b040c8b9a
fix: pin default meshcore web image 2026-06-23 04:47:07 +00:00
jakeaturner 475781ffa2
build: ensure openssl installed in admin container 2026-06-23 04:47:06 +00:00
Chris Sherwood bd65c885be
feat(supply-depot): add MeshCore Web with self-signed HTTPS
Adds the MeshCore web client to the Supply Depot catalog (host port 8500),
alongside the existing Meshtastic apps. Uses aXistem's prebuilt image of Liam
Cottle's MeshCore client (MeshCore is a sibling LoRa mesh project to Meshtastic).

The image is stock nginx serving a static Flutter build over HTTP, but the
client reaches radios via Web Bluetooth / Web Serial, which browsers only allow
from a secure (HTTPS) context. So we serve it over HTTPS: a new preinstall hook
generates a self-signed cert + a small SSL nginx config into storage/meshcore-web,
both bind-mounted into the container (the config over the image's default.conf),
publishing 443. Same one-time browser-warning approach as Vaultwarden, whose
openssl cert generation is refactored into a shared _ensureSelfSignedCert helper.

Also adds a NOMAD-specific docs section + Manage>Docs anchor, and registers the
IconAntenna icon. Meshtastic Web left unchanged.

Validated on NOMAD3 (v1.33.0-rc.1): the image + SSL config + self-signed cert
serves the MeshCore Flutter app over HTTPS 200 with working SPA fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 04:47:06 +00:00
johno10661 83576ec33d
feat(supply-depot): add uninstall for curated apps (#1006)
Curated catalog apps could be installed, stopped, and force-reinstalled,
but never removed — the only path off a device was manual docker + DB
surgery. Custom apps already had delete; this adds the equivalent for
curated apps.

POST /api/system/services/uninstall stops and removes the app's
container (optionally its image, same best-effort semantics as custom
app delete) and flips the record back to not-installed so the card
returns to the available catalog. Host bind-mount data is deliberately
left on disk, so a later reinstall picks the app back up where it left
off — unlike force-reinstall, which clears volumes.

Guards: custom apps are rejected (use delete), dependency services are
rejected, and uninstalling a not-installed app is a 409.

UI: installed curated cards get an Uninstall action in the card menu,
with a confirm modal that explains data is preserved and offers the
same remove-image checkbox as custom app delete.
2026-06-23 04:47:05 +00:00
Chris Sherwood e2beb7ebeb
docs: document Supply Depot + auto-updates in README
Bring the GitHub-facing README in line with the v1.33 feature set:

- Add Supply Depot (one-click app catalog + bring-your-own custom
  Docker containers) and Automatic Updates to the "Built-in
  capabilities" list and the "What's Included" table.
- Fix the Offline Maps row, which claimed "navigation" — NOMAD maps
  are download/view only, with no routing. Reword to "offline viewing
  and search."

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 04:47:05 +00:00
Chris Sherwood 536ad49277
docs: update in-app docs for v1.33 Supply Depot + auto-updates
Refresh the in-app Markdoc docs for the v1.33 feature set:

- Repoint dead /settings/apps links to the Supply Depot (/supply-depot)
  across home, getting-started, and faq; reword "Apps page" / "Settings
  -> Apps" to "Supply Depot". The old /apps route now redirects to the
  Supply Depot.
- Expand supply-depot-apps.md with a "Managing your apps" section (Docs/
  Edit/Logs/Stats/Update/Remove, version + update-available visibility,
  custom launch URLs, per-app auto-update toggle) and a "Bringing your
  own app" section for custom Docker containers.
- Add a new "Updates" doc (updates.md) covering the auto-update trilogy
  (core/apps/content), manual updates, and the Early Access channel;
  wire it into DOC_ORDER and cross-link from home, getting-started, faq.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 04:47:04 +00:00
jakeaturner 61ef8f3697
chore: force bump main version 2026-06-23 04:47:04 +00:00
jakeaturner 5eb208fb50
docs: update release notes 2026-06-23 04:47:03 +00:00
jakeaturner 37ad684fbd
fix(bullmq): bump to 5.77.6 and update set calls w new args shape 2026-06-23 04:47:03 +00:00
jakeaturner d4972445d9
chore(deps): bump autoprefixer 2026-06-23 04:47:02 +00:00
jakeaturner b6a08058e8
chore(deps): bump react and react-dom 2026-06-23 04:47:02 +00:00
jakeaturner 6a2d4c2bf6
feat(content): opt-in automatic updates for installed ZIM & map content 2026-06-23 04:47:01 +00:00
akashsalan 9de6473f6a
fix(system): prevent false offline reports when Cloudflare endpoint is unreachable 2026-06-23 04:47:01 +00:00
Chris Sherwood bbd62d8ed1
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-23 04:47:01 +00:00
John Onysko 2ae30a4abb
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-23 04:47:00 +00:00
jakeaturner e5565d1e95
refactor(supply-depot): extract version and subtitle helpers instead of IIFE for readability 2026-06-23 04:47:00 +00:00
Chris Sherwood 4ece29b6d4
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-23 04:46:59 +00:00
Chris Sherwood 5de58da4b7
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-23 04:46:59 +00:00
Chris Sherwood da60c6ce9c
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-23 04:46:58 +00:00
Chris Sherwood 9b84d3aa54
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-23 04:46:58 +00:00
Chris Sherwood a315ce0f54
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-23 04:46:57 +00:00
Chris Sherwood df47139846
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-23 04:46:57 +00:00
Chris Sherwood 663c1593df
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-23 04:46:56 +00:00
Chris Sherwood 1b9f4f30f2
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-23 04:46:56 +00:00
jakeaturner 026ab6df8f
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-23 04:46:56 +00:00
Chris Sherwood ca5ec1767f
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-23 04:46:55 +00:00
Chris Sherwood 98d235679e
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-23 04:46:55 +00:00
Chris Sherwood 36068c645e
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-23 04:46:54 +00:00
Chris Sherwood 3977c723c2
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-23 04:46:54 +00:00
Chris Sherwood 25000b9869
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-23 04:46:53 +00:00
jakeaturner dfc284c34d
docs: fix out of place JSDoc comment 2026-06-23 04:46:53 +00:00
Chris Sherwood 3f574e4003
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-23 04:46:52 +00:00
jakeaturner cf8db6218d
feat(supply-depot): opt-in automatic updates for installed apps 2026-06-23 04:46:52 +00:00
jakeaturner 02d985db5e
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-23 04:46:51 +00:00
Metbcy d175259219
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-23 04:46:51 +00:00
John Onysko f553a0d57d
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-23 04:46:51 +00:00
gujishh e1c7435fc4
fix(install): harden toolkit and helper script downloads 2026-06-23 04:46:50 +00:00
Chris Sherwood ccc221f88d
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-23 04:46:50 +00:00
jakeaturner 057fb693f6
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-23 04:46:49 +00:00
Chris Sherwood fab15c68a9
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-23 04:46:49 +00:00
jakeaturner b2bea191a8
fix: minor type guarding fixes after axios bump 2026-06-23 04:46:48 +00:00
jakeaturner 555ffa8790
fix(supply-depot): reintroduce app update UI 2026-06-23 04:46:48 +00:00
Chris Sherwood f488f08c96
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-23 04:46:47 +00:00
Chris Sherwood b8674193da
fix(supply-depot): show clean port + lock on card pill for https:port ui_location 2026-06-23 04:46:47 +00:00
Chris Sherwood 3501144caa
feat(supply-depot): scheme-aware service links (https:port) for TLS-serving apps 2026-06-23 04:46:47 +00:00
Chris Sherwood 7b1b480c5e
fix(supply-depot): correct Meshtastic Web internal port (80->8080); add catalog port audit script 2026-06-23 04:46:46 +00:00
Chris Sherwood 02c33b277e
feat: edit curated apps + fix dropdown clip + stale _old rollback 2026-06-23 04:46:46 +00:00
Jake Turner be434d755a
feat: supply depot 2026-06-23 04:46:45 +00:00
Chris Sherwood e38dbf8a65
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-23 04:46:45 +00:00
jakeaturner 7943ae5d1f
chore(deps): bump axios to 1.17.0 in admin 2026-06-23 04:46:44 +00:00
teccin 820c5c415e
Fix a few typos and punctuation in README.md 2026-06-23 04:46:44 +00:00
jakeaturner b8961d27dd
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-23 04:46:43 +00:00
Jake Turner 17630c048f
docs: update release notes 2026-06-23 04:46:43 +00:00
cosmistack-bot d5f7c3f615 docs(release): finalize v1.32.1 release notes [skip ci] 2026-05-27 22:36:20 +00:00
cosmistack-bot fa144c4777 chore(release): 1.32.1 [skip ci] 2026-05-27 22:36:12 +00:00
jakeaturner e26ce4f1f6 docs: update release notes 2026-05-27 15:32:28 -07:00
jakeaturner 8d2bf785e1 chore(deps): bump various deps 2026-05-27 15:32:28 -07:00
chriscrosstalk 97c65cca67 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-05-27 15:32:28 -07:00
chriscrosstalk 5bf5bc33b7 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-05-27 15:32:28 -07:00
chriscrosstalk 737e6784f0 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-05-27 15:32:28 -07:00
135 changed files with 15016 additions and 2073 deletions

View File

@ -4,6 +4,7 @@ FROM node:22-slim AS base
RUN apt-get update && apt-get install -y \
bash \
curl \
openssl \
graphicsmagick \
libvips-dev \
build-essential \
@ -84,6 +85,10 @@ RUN echo "{\"version\":\"${VERSION}\"}" > /app/version.json
COPY admin/docs /app/docs
COPY README.md /app/README.md
# Empty Calibre library, seeded into storage/books on Calibre-Web install
# (see DockerService._runPreinstallActions__CalibreWeb)
COPY install/calibre-empty-library/metadata.db /app/assets/calibre/metadata.db
# Copy entrypoint script and ensure it's executable
COPY install/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

View File

@ -14,7 +14,7 @@
---
Project N.O.M.A.D. is a self-contained, offline-first knowledge and education server packed with critical tools, knowledge, and AI to keep you informed and empowered—anytime, anywhere.
Project N.O.M.A.D. is a self-contained, offline-first knowledge and education server packed with critical tools, knowledge, and AI to keep you informed and empowered anytime, anywhere.
## Installation & Quickstart
Project N.O.M.A.D. can be installed on any Debian-based operating system (we recommend Ubuntu). Installation is completely terminal-based, and all tools and resources are designed to be accessed through the browser, so there's no need for a desktop environment if you'd rather setup N.O.M.A.D. as a "server" and access it through other clients.
@ -48,6 +48,8 @@ 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.
@ -59,18 +61,19 @@ 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 with search and navigation |
| Offline Maps | ProtoMaps | Downloadable regional maps for offline viewing and search |
| 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
available AI tools, we highly encourage the use of a beefy, GPU-backed device to make the most of your install.
At it's core, however, N.O.M.A.D. is still very lightweight. For a barebones installation of the management application itself, the following minimal specs are required:
At its core, however, N.O.M.A.D. is still very lightweight. For a barebones installation of the management application itself, the following minimal specs are required:
*Note: Project N.O.M.A.D. is not sponsored by any hardware manufacturer and is designed to be as hardware-agnostic as possible. The harware listed below is for example/comparison use only*
*Note: Project N.O.M.A.D. is not sponsored by any hardware manufacturer and is designed to be as hardware-agnostic as possible. The hardware listed below is for example/comparison use only*
#### Minimum Specs
- Processor: 2 GHz dual-core processor or better
@ -79,7 +82,7 @@ At it's core, however, N.O.M.A.D. is still very lightweight. For a barebones ins
- OS: Debian-based (Ubuntu recommended)
- Stable internet connection (required during install only)
To run LLM's and other included AI tools:
To run LLMs and other included AI tools:
#### Optimal Specs
- Processor: AMD Ryzen 7 or Intel Core i7 or better
@ -91,12 +94,12 @@ To run LLM's and other included AI tools:
**For detailed build recommendations at three price points ($150$1,000+), see the [Hardware Guide](https://www.projectnomad.us/hardware).**
Again, Project N.O.M.A.D. itself is quite lightweight - it's the tools and resources you choose to install with N.O.M.A.D. that will determine the specs required for your unique deployment
Again, Project N.O.M.A.D. itself is quite lightweight it's the tools and resources you choose to install with N.O.M.A.D. that will determine the specs required for your unique deployment
#### Running AI models on a different host
By default, N.O.M.A.D.'s installer will attempt to setup Ollama on the host when the AI Assistant is installed. However, if you would like to run the AI model on a different host, you can go to the settings of of the AI assistant and input a URL for either an ollama or OpenAI-compatible API server (such as LM Studio).
Note that if you use Ollama on a different host, you must start the server with this option `OLLAMA_HOST=0.0.0.0`.
Ollama is the preferred way to use the AI assistant as it has features such as model download that OpenAI API does not support. So when using LM Studio for example, you will have to use LM Studio to download models.
By default, N.O.M.A.D.'s installer will attempt to setup Ollama on the host when the AI Assistant is installed. However, if you would like to run the AI model on a different host, you can go to the settings of the AI assistant and input a URL for either an ollama or OpenAI-compatible API server (such as LM Studio).
Note that if you use Ollama on a different host, you must start the server with this option: `OLLAMA_HOST=0.0.0.0`.
Ollama is the preferred way to use the AI assistant, as it has features such as model download that OpenAI API does not support. So when using LM Studio, for example, you will have to use LM Studio to download models.
You are responsible for the setup of Ollama/OpenAI server on the other host.
## Frequently Asked Questions (FAQ)
@ -105,24 +108,60 @@ 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. attempts to make a request to Cloudflare's utility endpoint, `https://1.1.1.1/cdn-cgi/trace` and checks for a successful response.
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.
## About Security
By design, Project N.O.M.A.D. is intended to be open and available without hurdles - it includes no authentication. If you decide to connect your device to a local network after install (e.g. for allowing other devices to access it's resources), you can block/open ports to control which services are exposed.
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.
**Will authentication be added in the future?** Maybe. It's not currently a priority, but if there's enough demand for it, we may consider building in an optional authentication layer in a future release to support uses cases where multiple users need access to the same instance but with different permission levels (e.g. family use with parental controls, classroom use with teacher/admin accounts, etc.). We have a suggestion for this on our public roadmap, so if this is something you'd like to see, please upvote it here: https://roadmap.projectnomad.us/posts/1/user-authentication-please-build-in-user-auth-with-admin-user-roles
**Will authentication be added in the future?** Maybe. It's not currently a priority, but if there's enough demand for it, we may consider building in an optional authentication layer in a future release to support use cases where multiple users need access to the same instance but with different permission levels (e.g. family use with parental controls, classroom use with teacher/admin accounts, etc.). We have a suggestion for this on our public roadmap, so if this is something you'd like to see, please upvote it here: https://roadmap.projectnomad.us/posts/1/user-authentication-please-build-in-user-auth-with-admin-user-roles
For now, we recommend using network-level controls to manage access if you're planning to expose your N.O.M.A.D. instance to other devices on a local network. N.O.M.A.D. is not designed to be exposed directly to the internet, and we strongly advise against doing so unless you really know what you're doing, have taken appropriate security measures, and understand the risks involved.
## Contributing
Contributions are welcome and appreciated! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to contribute to the project.
### Testing Auto-Updates (Dry Run)
The Command Center can automatically install **minor/patch** updates of itself during a configurable window, after a cool-off period, and only when pre-flight checks pass (sufficient disk for the new image, no downloads or app installs in progress). Major versions always require a manual update.
Because exercising this logic with real version bumps is impractical, an Ace command runs the **entire decision pipeline without ever triggering an update**. Run it from the `admin/` directory:
```bash
# 1) Deterministic scenario suite — no network, DB, or Docker required.
# Proves every branch (major-only, cool-off, prerelease/draft, window wrap, …)
# and exits non-zero on failure, so it's safe to wire into CI.
node ace auto-update:dry-run --scenarios
# 2) Simulate "what would happen if I were running 1.32.0 right now?"
# against the LIVE GitHub releases feed and real pre-flight checks:
node ace auto-update:dry-run --current=1.32.0 --force-enabled
# 3) Fully offline simulation with a canned release list and a fixed clock:
node ace auto-update:dry-run --current=1.32.0 --force-enabled \
--releases-file=./fixtures/releases.json --now=2026-06-04T21:00:00Z \
--window-start=20:00 --window-end=23:00 --cooloff=72 --skip-preflight
```
It prints the resolved decision — current version, whether the clock is inside the window, the eligible target (if any), and pre-flight blockers — ending in a clear verdict such as `WOULD UPDATE → v1.33.2` or `WOULD NOT UPDATE (outside-window): …`. **No real update is ever requested.**
| Flag | Description |
|------|-------------|
| `--scenarios` | Run the built-in deterministic scenario suite and exit |
| `--current=<version>` | Simulate this currently-running version (e.g. `1.32.0`) |
| `--force-enabled` | Treat auto-update as enabled, ignoring the saved setting |
| `--cooloff=<hours>` | Override the cool-off period |
| `--window-start=<HH:MM>` / `--window-end=<HH:MM>` | Override the update window |
| `--now=<ISO timestamp>` | Simulate the clock at a specific time |
| `--releases-file=<path>` | Use a local JSON releases array instead of fetching GitHub (offline) |
| `--skip-preflight` | Bypass the Docker/disk/queue pre-flight checks |
## Community & Resources
- **Website:** [www.projectnomad.us](https://www.projectnomad.us) - Learn more about the project
- **Discord:** [Join the Community](https://discord.com/invite/crosstalksolutions) - Get help, share your builds, and connect with other NOMAD users
- **Benchmark Leaderboard:** [benchmark.projectnomad.us](https://benchmark.projectnomad.us) - See how your hardware stacks up against other NOMAD builds
- **Troubleshooting Guide:** [TROUBLESHOOTING.md](TROUBLESHOOTING.md) - Find solutions to common issues
- **FAQ:** [FAQ.md](FAQ.md) - Find answers to frequently asked questions
- **Community Add-Ons:** [admin/docs/community-add-ons.md](admin/docs/community-add-ons.md) - Third-party content packs built by the community

View File

@ -1,6 +1,10 @@
PORT=8080
HOST=localhost
LOG_LEVEL=info
# Optional: override the URL used to test internet connectivity.
# Defaults to https://1.1.1.1/cdn-cgi/trace with fallbacks to hosts the app
# already contacts. Leave unset to use the defaults.
# INTERNET_STATUS_TEST_URL=https://1.1.1.1/cdn-cgi/trace
APP_KEY=some_random_key
NODE_ENV=development
SESSION_DRIVER=cookie
@ -12,6 +16,9 @@ DB_PASSWORD=password
DB_SSL=false
REDIS_HOST=localhost
REDIS_PORT=6379
# Optional: Redis logical database index (0-15). Defaults to 0 if unset.
# Set this when sharing a Redis instance across services to avoid key collisions.
# REDIS_DB=0
# Storage path for NOMAD content (ZIM files, maps, etc.)
# On Windows dev, use an absolute path like: C:/nomad-storage
# On Linux production, use: /opt/project-nomad/storage

View File

@ -106,8 +106,17 @@ export default class OllamaController {
`[RAG] Injecting ${trimmedDocs.length}/${relevantDocs.length} results (model: ${reqData.model}, maxResults: ${maxResults}, maxTokens: ${maxTokens || 'unlimited'})`
)
// Label each context block with its source title when available (a neutral,
// honest provenance signal) but never the raw relevance score — nomic cosine
// scores for genuinely relevant passages sit ~0.4-0.6, and surfacing e.g.
// "42%" primes the model to distrust correct context. Scores stay in the logs
// above for debugging.
const contextText = trimmedDocs
.map((doc, idx) => `[Context ${idx + 1}] (Relevance: ${(doc.score * 100).toFixed(1)}%)\n${doc.text}`)
.map((doc, idx) => {
const title = doc.metadata?.full_title || doc.metadata?.article_title
const label = title ? `[Context ${idx + 1}${title}]` : `[Context ${idx + 1}]`
return `${label}\n${doc.text}`
})
.join('\n\n')
const systemMessage = {

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, getJobStatusSchema } from '#validators/rag'
import { deleteFileSchema, embedFileSchema, estimateBatchSchema, fileSourceSchema, getJobStatusSchema } from '#validators/rag'
import logger from '@adonisjs/core/services/logger'
@inject()
@ -110,6 +110,14 @@ 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)
@ -162,4 +170,23 @@ 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

@ -3,9 +3,10 @@ import { BenchmarkService } from '#services/benchmark_service'
import { MapService } from '#services/map_service'
import { OllamaService } from '#services/ollama_service'
import { SystemService } from '#services/system_service'
import { getSettingSchema, updateSettingSchema } from '#validators/settings'
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 {
@ -110,6 +111,19 @@ 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);
@ -118,6 +132,10 @@ export default class SettingsController {
async updateSetting({ request, response }: HttpContext) {
const reqData = await request.validateUsing(updateSettingSchema)
const valueError = validateSettingValue(reqData.key, reqData.value)
if (valueError) {
return response.status(422).send({ success: false, message: valueError })
}
await this.systemService.updateSetting(reqData.key, reqData.value)
return response.status(200).send({ success: true, message: 'Setting updated successfully' })
}

View File

@ -0,0 +1,13 @@
import { SystemService } from '#services/system_service'
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
@inject()
export default class SupplyDepotController {
constructor(private systemService: SystemService) {}
async index({ inertia }: HttpContext) {
const services = await this.systemService.getServices({ installedOnly: false })
return inertia.render('supply-depot', { system: { services } })
}
}

View File

@ -2,11 +2,38 @@ import { DockerService } from '#services/docker_service';
import { SystemService } from '#services/system_service'
import { SystemUpdateService } from '#services/system_update_service'
import { ContainerRegistryService } from '#services/container_registry_service'
import { AutoUpdateService } from '#services/auto_update_service'
import { AppAutoUpdateService } from '#services/app_auto_update_service'
import { ContentAutoUpdateService } from '#services/content_auto_update_service'
import { DownloadService } from '#services/download_service'
import { QueueService } from '#services/queue_service'
import { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job'
import { affectServiceValidator, checkLatestVersionValidator, installServiceValidator, subscribeToReleaseNotesValidator, updateServiceValidator } from '#validators/system';
import {
affectServiceValidator,
checkLatestVersionValidator,
customAppValidator,
deleteCustomAppValidator,
installServiceValidator,
preflightCustomValidator,
preflightValidator,
serviceLogsValidator,
subscribeToReleaseNotesValidator,
uninstallServiceValidator,
updateCustomAppValidator,
updateServiceValidator,
setServiceAutoUpdateValidator,
setServiceCustomUrlValidator,
normalizeCustomUrl,
} from '#validators/system'
import {
DEFAULT_CPUS,
DEFAULT_MEMORY_MB,
evaluateCustomApp,
} from '#services/custom_app_guard'
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import logger from '@adonisjs/core/services/logger'
import Service from '#models/service'
@inject()
export default class SystemController {
@ -108,6 +135,82 @@ export default class SystemController {
response.send({ logs });
}
async getAutoUpdateStatus({ response }: HttpContext) {
// Construct inline reusing already-injected singletons + the QueueService
// singleton (its constructor is private to prevent Redis connection leaks,
// so we must not let the container new a fresh one).
const autoUpdateService = new AutoUpdateService(
this.dockerService,
new DownloadService(QueueService.getInstance()),
this.systemService,
this.systemUpdateService,
this.containerRegistryService
)
try {
const status = await autoUpdateService.getStatus()
response.send(status)
} catch (error) {
logger.error({ err: error }, '[SystemController] Failed to get auto-update status')
response.status(500).send({ error: 'Failed to retrieve auto-update status' })
}
}
async getAppAutoUpdateStatus({ response }: HttpContext) {
// Constructed inline reusing already-injected singletons + the QueueService
// singleton (its constructor is private to prevent Redis connection leaks),
// mirroring getAutoUpdateStatus. Apps need no SystemUpdateService (no sidecar).
const appAutoUpdateService = new AppAutoUpdateService(
this.dockerService,
new DownloadService(QueueService.getInstance()),
this.systemService,
this.containerRegistryService
)
try {
const status = await appAutoUpdateService.getStatus()
response.send(status)
} catch (error) {
logger.error({ err: error }, '[SystemController] Failed to get app auto-update status')
response.status(500).send({ error: 'Failed to retrieve app auto-update status' })
}
}
async getContentAutoUpdateStatus({ response }: HttpContext) {
// Mirrors getAppAutoUpdateStatus. Content auto-update needs only the
// DownloadService (for the active-download pre-flight); the catalog and
// collection-update services default-construct inside the service.
const contentAutoUpdateService = new ContentAutoUpdateService(
new DownloadService(QueueService.getInstance())
)
try {
const status = await contentAutoUpdateService.getStatus()
response.send(status)
} catch (error) {
logger.error({ err: error }, '[SystemController] Failed to get content auto-update status')
response.status(500).send({ error: 'Failed to retrieve content auto-update status' })
}
}
async setServiceAutoUpdate({ request, response }: HttpContext) {
const payload = await request.validateUsing(setServiceAutoUpdateValidator)
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` })
}
service.auto_update_enabled = payload.enabled
// Re-enabling clears any prior self-disable so the app gets a fresh start.
if (payload.enabled) {
service.auto_update_consecutive_failures = 0
service.auto_update_disabled_reason = null
}
await service.save()
return response.send({ success: true, message: 'App auto-update preference updated' })
}
async subscribeToReleaseNotes({ request }: HttpContext) {
const reqData = await request.validateUsing(subscribeToReleaseNotesValidator);
@ -180,4 +283,525 @@ export default class SystemController {
return 'amd64'
}
}
/**
* Pre-install preflight check: reports port conflicts and resource warnings for a service.
* Results are advisory the UI shows warnings but allows the user to force-proceed.
*/
async preflightCheck({ request, response }: HttpContext) {
const payload = await request.validateUsing(preflightValidator)
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` })
}
// Extract host ports from container_config — the MySQL driver may return JSON columns
// as an already-parsed object rather than a string, so guard before calling JSON.parse.
const rawConfig = service.container_config
const config = rawConfig
? typeof rawConfig === 'object'
? rawConfig
: JSON.parse(rawConfig as string)
: null
const portBindings: Record<string, [{ HostPort: string }]> =
config?.HostConfig?.PortBindings ?? {}
const hostPorts = Object.values(portBindings)
.flat()
.map((b) => parseInt(b.HostPort, 10))
.filter((p) => !isNaN(p))
// Parse resource requirements from metadata (same object-guard as container_config)
let minMemoryMB = 256
let minDiskMB = 512
try {
const rawMeta = service.metadata
const meta = rawMeta
? typeof rawMeta === 'object'
? rawMeta
: JSON.parse(rawMeta as string)
: null
if (meta?.minMemoryMB) minMemoryMB = meta.minMemoryMB
if (meta?.minDiskMB) minDiskMB = meta.minDiskMB
} catch {}
const [{ conflicts: portConflicts }, resourceWarnings] = await Promise.all([
this.dockerService.checkPortConflicts(hostPorts),
this.systemService.checkResourceWarnings(minMemoryMB, minDiskMB),
])
return response.send({ portConflicts, resourceWarnings })
}
/** Return the next suggested host port for a custom app (8600+ range). */
async suggestCustomPort({ response }: HttpContext) {
const port = await this.systemService.getNextSuggestedCustomPort()
return response.send({ port })
}
/**
* Service-less preflight for the custom-app form: given host ports, volumes and an image,
* report port conflicts, host resource warnings, overridable guard warnings (risky bind
* mounts / untrusted or moving-tag images), and hard blocks (docker socket, system dirs,
* malformed image). Lets the form give live feedback before a Service record exists.
*/
async preflightCustomApp({ request, response }: HttpContext) {
const payload = await request.validateUsing(preflightCustomValidator)
const [{ conflicts }, resourceWarnings] = await Promise.all([
this.dockerService.checkPortConflicts(payload.ports ?? []),
this.systemService.checkResourceWarnings(256, 512),
])
// When editing, the app's own container legitimately holds its ports — don't flag those.
const portConflicts = payload.exclude_service
? conflicts.filter((c) => c.usedBy !== payload.exclude_service)
: conflicts
const guard = evaluateCustomApp({ image: payload.image, volumes: payload.volumes })
return response.send({
portConflicts,
resourceWarnings: [...resourceWarnings, ...guard.warnings],
blocked: guard.blocked,
})
}
/** Create and immediately begin installing a custom app container. */
async createCustomApp({ request, response }: HttpContext) {
const payload = await request.validateUsing(customAppValidator)
// Derive a stable service_name from the friendly name
const slug = payload.friendly_name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
const serviceName = `nomad_custom_${slug}`
const existing = await Service.query().where('service_name', serviceName).first()
if (existing) {
return response.status(409).send({
success: false,
message: `A custom app named "${payload.friendly_name}" already exists. Choose a different name.`,
})
}
// Reject duplicate host ports within the request — Docker would otherwise fail at
// start time with an opaque "port is already allocated" error.
const hostPorts = (payload.ports ?? []).map((p) => p.host)
const duplicateHostPorts = [...new Set(hostPorts.filter((p, i) => hostPorts.indexOf(p) !== i))]
if (duplicateHostPorts.length) {
return response.status(422).send({
success: false,
message: `Duplicate host port(s): ${duplicateHostPorts.join(', ')}. Each host port can map to only one container.`,
})
}
// Security guardrails: hard-block dangerous bind mounts / malformed images regardless of
// force; surface overridable warnings (risky paths, untrusted/moving-tag images) unless forced.
const guard = evaluateCustomApp({ image: payload.image, volumes: payload.volumes })
if (guard.blocked.length) {
return response.status(422).send({
success: false,
message: guard.blocked.join(' '),
blocked: guard.blocked,
})
}
if (!payload.force && guard.warnings.length) {
return response.status(409).send({
success: false,
message: guard.warnings.join(' '),
warnings: guard.warnings,
})
}
// Advisory preflight: surface port conflicts before creating the record so a failed
// install doesn't leave a phantom card. The user can re-submit with force=true to override.
if (!payload.force && hostPorts.length) {
const { conflicts } = await this.dockerService.checkPortConflicts(hostPorts)
if (conflicts.length) {
return response.status(409).send({
success: false,
message: `Port conflict: ${conflicts
.map((c) => `${c.port} (in use by ${c.usedBy})`)
.join(', ')}.`,
portConflicts: conflicts,
})
}
}
const { containerConfig, uiLocation } = this.buildCustomContainerConfig(payload)
await Service.create({
service_name: serviceName,
friendly_name: payload.friendly_name,
container_image: payload.image,
container_config: JSON.stringify(containerConfig),
ui_location: uiLocation,
icon: payload.icon || 'IconBrandDocker',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: true,
category: payload.category ?? 'custom',
depends_on: null,
})
const result = await this.dockerService.createContainerPreflight(serviceName)
if (result.success) {
return response.send({ success: true, message: result.message, service_name: serviceName })
}
return response.status(400).send({ success: false, message: result.message })
}
/** Delete a custom app: stop + remove its container, then delete the DB record. */
async deleteCustomApp({ request, response }: HttpContext) {
const payload = await request.validateUsing(deleteCustomAppValidator)
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: 'Only custom apps can be deleted.' })
}
await this.dockerService.removeCustomAppContainer(payload.service_name, payload.remove_image ?? false)
await service.delete()
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. */
async setServiceCustomUrl({ request, response }: HttpContext) {
const payload = await request.validateUsing(setServiceCustomUrlValidator)
const service = await Service.query().where('service_name', payload.service_name).first()
if (!service) {
return response.status(404).send({ success: false, message: `Service ${payload.service_name} not found` })
}
// Hidden dependency services (e.g. Qdrant) aren't user-launchable, so they have no link to set.
if (service.is_dependency_service) {
return response.status(403).send({ success: false, message: 'This service cannot be configured.' })
}
// Reject a non-empty value that isn't a valid http(s) URL; an empty value clears the override.
const normalized = normalizeCustomUrl(payload.custom_url)
if (payload.custom_url && payload.custom_url.trim() && !normalized) {
return response.status(422).send({
success: false,
message: 'Custom URL must be a valid http(s) address (e.g. https://jellyfin.myhomelab.net).',
})
}
service.custom_url = normalized
await service.save()
return response.send({ success: true, custom_url: service.custom_url })
}
/** Re-pull a custom app's image and recreate its container in place (preserving volumes). */
async updateCustomApp_pullLatest({ request, response }: HttpContext) {
const payload = await request.validateUsing(installServiceValidator)
const service = await Service.query().where('service_name', payload.service_name).first()
if (!service) {
return response.status(404).send({ success: false, message: `Service ${payload.service_name} not found` })
}
if (!service.is_custom) {
return response.status(403).send({ success: false, message: 'Only custom apps can be updated this way.' })
}
const result = await this.dockerService.recreateCustomAppContainer(payload.service_name, {
forcePull: true,
})
if (result.success) {
return response.send({ success: true, message: result.message })
}
return response.status(400).send({ success: false, message: result.message })
}
/** Return the last N lines of a service container's logs. */
async getServiceLogs({ params, request, response }: HttpContext) {
// Scope to managed services only — otherwise any sibling container's logs (admin app,
// database) would be readable by name on this unauthenticated API surface.
const service = await Service.query().where('service_name', params.name).first()
if (!service) {
return response.status(404).send({ success: false, message: `Service ${params.name} not found` })
}
const { tail } = await request.validateUsing(serviceLogsValidator)
const result = await this.dockerService.getContainerLogs(params.name, tail ?? 200)
if (!result.success) {
return response.status(404).send({ success: false, message: result.message })
}
return response.send({ success: true, logs: result.logs })
}
/** Return a one-shot CPU/memory usage snapshot for a running service container. */
async getServiceStats({ params, response }: HttpContext) {
// Scope to managed services only (see getServiceLogs).
const service = await Service.query().where('service_name', params.name).first()
if (!service) {
return response.status(404).send({ success: false, message: `Service ${params.name} not found` })
}
const result = await this.dockerService.getContainerStats(params.name)
if (!result.success) {
return response.status(404).send({ success: false, message: result.message })
}
return response.send({ success: true, running: result.running ?? false, stats: result.stats ?? null })
}
/** Return an app's current configuration in the editable form-shape. */
async getCustomApp({ params, response }: HttpContext) {
const service = await Service.query().where('service_name', params.name).first()
if (!service) {
return response.status(404).send({ error: `Service ${params.name} not found` })
}
// Custom and curated apps are both editable; hidden dependency services (e.g. Qdrant) are not.
if (service.is_dependency_service) {
return response.status(403).send({ error: 'This service cannot be edited.' })
}
return response.send({ success: true, app: this.parseCustomContainerConfig(service) })
}
/** Reconfigure an app: validate + guard, persist the new config, then recreate the container.
* Works for both custom apps and curated (pre-configured) apps. Editing a curated app marks it
* user-modified so the seeder stops overwriting the user's changes. */
async updateCustomApp({ request, response }: HttpContext) {
const payload = await request.validateUsing(updateCustomAppValidator)
const service = await Service.query().where('service_name', payload.service_name).first()
if (!service) {
return response.status(404).send({ success: false, message: `Service ${payload.service_name} not found` })
}
// Custom and curated apps are both editable; hidden dependency services (e.g. Qdrant) are not.
if (service.is_dependency_service) {
return response.status(403).send({ success: false, message: 'This service cannot be edited.' })
}
// Reject duplicate host ports within the request.
const hostPorts = (payload.ports ?? []).map((p) => p.host)
const duplicateHostPorts = [...new Set(hostPorts.filter((p, i) => hostPorts.indexOf(p) !== i))]
if (duplicateHostPorts.length) {
return response.status(422).send({
success: false,
message: `Duplicate host port(s): ${duplicateHostPorts.join(', ')}. Each host port can map to only one container.`,
})
}
// Security guardrails (same posture as create).
const guard = evaluateCustomApp({ image: payload.image, volumes: payload.volumes })
if (guard.blocked.length) {
return response.status(422).send({ success: false, message: guard.blocked.join(' '), blocked: guard.blocked })
}
if (!payload.force && guard.warnings.length) {
return response.status(409).send({ success: false, message: guard.warnings.join(' '), warnings: guard.warnings })
}
// Port conflicts — but ignore ports already held by this app's own container.
if (!payload.force && hostPorts.length) {
const { conflicts } = await this.dockerService.checkPortConflicts(hostPorts)
const external = conflicts.filter((c) => c.usedBy !== payload.service_name)
if (external.length) {
return response.status(409).send({
success: false,
message: `Port conflict: ${external
.map((c) => `${c.port} (in use by ${c.usedBy})`)
.join(', ')}.`,
portConflicts: external,
})
}
}
// Merge the form fields into the app's existing config rather than rebuilding from scratch,
// so advanced settings a curated app ships with (GPU device requests, special env, etc.) are
// preserved across an edit.
// Preserve an explicit scheme (e.g. ui_location "https:8480") across an edit — otherwise a
// TLS-serving app's Open link would silently revert to http after any reconfigure.
const prevScheme = (service.ui_location || '').match(/^(https?):\d+$/)?.[1]
const { containerConfig, uiLocation } = this.mergeCustomContainerConfig(
service.container_config,
payload
)
service.friendly_name = payload.friendly_name
service.container_image = payload.image
service.container_config = JSON.stringify(containerConfig)
service.ui_location = prevScheme && uiLocation && /^\d+$/.test(uiLocation)
? `${prevScheme}:${uiLocation}`
: uiLocation
service.category = payload.category ?? service.category ?? 'custom'
if (payload.icon) service.icon = payload.icon
// Flag as user-modified so the seeder stops overwriting this app's config on future runs.
service.is_user_modified = true
await service.save()
const result = await this.dockerService.recreateCustomAppContainer(payload.service_name)
if (result.success) {
return response.send({ success: true, message: result.message, service_name: payload.service_name })
}
return response.status(400).send({ success: false, message: result.message })
}
/**
* Build a Docker container config (HostConfig + ExposedPorts + Env) from custom-app form input,
* applying default resource caps. Shared by create and update so both stay in lockstep.
*/
private buildCustomContainerConfig(payload: {
ports?: { container: number; host: number }[]
volumes?: { host_path: string; container_path: string }[]
env?: string[]
memory_mb?: number
cpus?: number
}): { containerConfig: Record<string, any>; uiLocation: string | null } {
const portBindings: Record<string, [{ HostPort: string }]> = {}
const exposedPorts: Record<string, {}> = {}
for (const { container, host } of payload.ports ?? []) {
portBindings[`${container}/tcp`] = [{ HostPort: String(host) }]
exposedPorts[`${container}/tcp`] = {}
}
const binds = (payload.volumes ?? []).map(
({ host_path, container_path }) => `${host_path}:${container_path}`
)
// Resource caps so a runaway custom container can't starve the host. Memory is bytes;
// NanoCpus is CPUs × 1e9. Defaults are generous and user-overridable.
const memoryBytes = (payload.memory_mb ?? DEFAULT_MEMORY_MB) * 1024 * 1024
const nanoCpus = Math.round((payload.cpus ?? DEFAULT_CPUS) * 1e9)
const containerConfig: Record<string, any> = {
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
PortBindings: portBindings,
Memory: memoryBytes,
NanoCpus: nanoCpus,
...(binds.length ? { Binds: binds } : {}),
},
ExposedPorts: exposedPorts,
...(payload.env?.length ? { Env: payload.env } : {}),
}
const firstHostPort = payload.ports?.[0]?.host
const uiLocation = firstHostPort ? String(firstHostPort) : null
return { containerConfig, uiLocation }
}
/**
* Merge custom-app form input into an app's *existing* container config. Used by the edit path so
* editing a curated app only changes the fields exposed in the form (image/ports/volumes/env and,
* if supplied, resource caps) while preserving everything else it ships with (GPU DeviceRequests,
* User, custom HostConfig keys, etc.). Unlike buildCustomContainerConfig, resource caps are NOT
* defaulted here a curated app intentionally left uncapped stays uncapped unless the user sets one.
*/
private mergeCustomContainerConfig(
existingRaw: string | null,
payload: {
ports?: { container: number; host: number }[]
volumes?: { host_path: string; container_path: string }[]
env?: string[]
memory_mb?: number
cpus?: number
}
): { containerConfig: Record<string, any>; uiLocation: string | null } {
const parsed = existingRaw
? typeof existingRaw === 'object'
? existingRaw
: JSON.parse(existingRaw as string)
: {}
// Deep clone so we never mutate the parsed source.
const containerConfig: Record<string, any> = JSON.parse(JSON.stringify(parsed ?? {}))
containerConfig.HostConfig = containerConfig.HostConfig ?? {}
// Keep a restart policy if the existing config lacked one.
containerConfig.HostConfig.RestartPolicy =
containerConfig.HostConfig.RestartPolicy ?? { Name: 'unless-stopped' }
const portBindings: Record<string, [{ HostPort: string }]> = {}
const exposedPorts: Record<string, {}> = {}
for (const { container, host } of payload.ports ?? []) {
portBindings[`${container}/tcp`] = [{ HostPort: String(host) }]
exposedPorts[`${container}/tcp`] = {}
}
containerConfig.HostConfig.PortBindings = portBindings
containerConfig.ExposedPorts = exposedPorts
const binds = (payload.volumes ?? []).map(
({ host_path, container_path }) => `${host_path}:${container_path}`
)
if (binds.length) containerConfig.HostConfig.Binds = binds
else delete containerConfig.HostConfig.Binds
if (payload.env?.length) containerConfig.Env = payload.env
else delete containerConfig.Env
// Only touch resource caps when the user explicitly set them — preserve existing/uncapped otherwise.
if (payload.memory_mb != null) {
containerConfig.HostConfig.Memory = payload.memory_mb * 1024 * 1024
}
if (payload.cpus != null) {
containerConfig.HostConfig.NanoCpus = Math.round(payload.cpus * 1e9)
}
const firstHostPort = payload.ports?.[0]?.host
const uiLocation = firstHostPort ? String(firstHostPort) : null
return { containerConfig, uiLocation }
}
/** Inverse of buildCustomContainerConfig: turn a stored Service into the editable form-shape. */
private parseCustomContainerConfig(service: Service) {
const raw = service.container_config
const config = raw ? (typeof raw === 'object' ? raw : JSON.parse(raw as string)) : {}
const hostConfig = config?.HostConfig ?? {}
const ports = Object.entries(hostConfig.PortBindings ?? {}).map(([key, val]: [string, any]) => ({
container: Number.parseInt(key, 10),
host: Number.parseInt(val?.[0]?.HostPort, 10),
}))
const volumes = (hostConfig.Binds ?? []).map((bind: string) => {
const idx = bind.indexOf(':')
return { host_path: bind.slice(0, idx), container_path: bind.slice(idx + 1) }
})
return {
service_name: service.service_name,
friendly_name: service.friendly_name,
image: service.container_image,
category: service.category ?? 'custom',
icon: service.icon ?? 'IconBrandDocker',
ports,
volumes,
env: (config?.Env ?? []) as string[],
memory_mb: hostConfig.Memory ? Math.round(hostConfig.Memory / (1024 * 1024)) : undefined,
cpus: hostConfig.NanoCpus ? hostConfig.NanoCpus / 1e9 : undefined,
}
}
}

View File

@ -8,7 +8,12 @@ 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 {
@ -56,6 +61,14 @@ export default class ZimController {
}
}
async rescanLibrary({}: HttpContext) {
const result = await this.zimService.rescanLibrary()
return {
message: 'Kiwix library rescanned',
...result,
}
}
async delete({ request, response }: HttpContext) {
const payload = await request.validateUsing(filenameParamValidator)
@ -75,6 +88,87 @@ 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

@ -0,0 +1,78 @@
import { Job } from 'bullmq'
import { QueueService } from '#services/queue_service'
import { DockerService } from '#services/docker_service'
import { DownloadService } from '#services/download_service'
import { SystemService } from '#services/system_service'
import { ContainerRegistryService } from '#services/container_registry_service'
import { AppAutoUpdateService } from '#services/app_auto_update_service'
import logger from '@adonisjs/core/services/logger'
/**
* Hourly job that evaluates whether any opted-in installed apps should auto-update
* right now and, if so, updates them. All gating (master switch, per-app opt-in,
* window, cool-off, pre-flight, per-app backoff) lives in {@link AppAutoUpdateService};
* this job is just the scheduled trigger. Runs hourly so it can act anywhere inside a
* user's window regardless of the window's length. Mirrors {@link AutoUpdateJob}.
*/
export class AppAutoUpdateJob {
static get queue() {
return 'system'
}
static get key() {
return 'app-auto-update'
}
async handle(_job: Job) {
logger.info('[AppAutoUpdateJob] Evaluating app auto-updates...')
const dockerService = new DockerService()
const appAutoUpdateService = new AppAutoUpdateService(
dockerService,
new DownloadService(QueueService.getInstance()),
new SystemService(dockerService),
new ContainerRegistryService()
)
const result = await appAutoUpdateService.attempt()
logger.info(`[AppAutoUpdateJob] ${result.updated} updated: ${result.reason}`)
return result
}
static async schedule() {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
await queue.upsertJobScheduler(
'hourly-app-auto-update',
{ pattern: '0 * * * *' }, // Top of every hour; attempt() gates on the window
{
name: this.key,
opts: {
removeOnComplete: { count: 12 },
removeOnFail: { count: 5 },
},
}
)
logger.info('[AppAutoUpdateJob] App auto-update evaluation scheduled with cron: 0 * * * *')
}
static async dispatch() {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const job = await queue.add(
this.key,
{},
{
attempts: 1,
removeOnComplete: { count: 12 },
removeOnFail: { count: 5 },
}
)
logger.info(`[AppAutoUpdateJob] Dispatched ad-hoc app auto-update evaluation job ${job.id}`)
return job
}
}

View File

@ -0,0 +1,80 @@
import { Job } from 'bullmq'
import { QueueService } from '#services/queue_service'
import { DockerService } from '#services/docker_service'
import { DownloadService } from '#services/download_service'
import { SystemService } from '#services/system_service'
import { SystemUpdateService } from '#services/system_update_service'
import { ContainerRegistryService } from '#services/container_registry_service'
import { AutoUpdateService } from '#services/auto_update_service'
import logger from '@adonisjs/core/services/logger'
/**
* Hourly job that evaluates whether the NOMAD application should auto-update right
* now and, if so, requests it. All gating (opt-in, window, eligibility, cool-off,
* pre-flight, backoff) lives in {@link AutoUpdateService}; this job is just the
* scheduled trigger. Runs hourly so it can act anywhere inside a user's window
* regardless of the window's length.
*/
export class AutoUpdateJob {
static get queue() {
return 'system'
}
static get key() {
return 'auto-update'
}
async handle(_job: Job) {
logger.info('[AutoUpdateJob] Evaluating auto-update...')
const dockerService = new DockerService()
const autoUpdateService = new AutoUpdateService(
dockerService,
new DownloadService(QueueService.getInstance()),
new SystemService(dockerService),
new SystemUpdateService(),
new ContainerRegistryService()
)
const result = await autoUpdateService.attempt()
logger.info(`[AutoUpdateJob] ${result.updated ? 'Updating' : 'No update'}: ${result.reason}`)
return result
}
static async schedule() {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
await queue.upsertJobScheduler(
'hourly-auto-update',
{ pattern: '0 * * * *' }, // Top of every hour; attempt() gates on the window
{
name: this.key,
opts: {
removeOnComplete: { count: 12 },
removeOnFail: { count: 5 },
},
}
)
logger.info('[AutoUpdateJob] Auto-update evaluation scheduled with cron: 0 * * * *')
}
static async dispatch() {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const job = await queue.add(
this.key,
{},
{
attempts: 1,
removeOnComplete: { count: 12 },
removeOnFail: { count: 5 },
}
)
logger.info(`[AutoUpdateJob] Dispatched ad-hoc auto-update evaluation job ${job.id}`)
return job
}
}

View File

@ -39,6 +39,14 @@ export class CheckServiceUpdatesJob {
const latestUpdate = updates.length > 0 ? updates[0].tag : null
// Stamp/clear the cool-off anchor only when the available version *changes*.
// Registry tags carry no publish date, so the auto-update cool-off is measured
// from when a version was first detected; leaving the timestamp untouched while
// the same version persists keeps the cool-off clock running.
if (latestUpdate !== service.available_update_version) {
service.available_update_first_seen_at = latestUpdate ? DateTime.now() : null
}
service.available_update_version = latestUpdate
service.update_checked_at = DateTime.now()
await service.save()

View File

@ -0,0 +1,72 @@
import { Job } from 'bullmq'
import { QueueService } from '#services/queue_service'
import { DownloadService } from '#services/download_service'
import { ContentAutoUpdateService } from '#services/content_auto_update_service'
import logger from '@adonisjs/core/services/logger'
/**
* Hourly job that evaluates whether any installed content (ZIM/map) should
* auto-update right now and, if so, dispatches the downloads. All gating (master
* switch, content window, cool-off, per-window data cap, pre-flight, backoff)
* lives in {@link ContentAutoUpdateService}; this job is just the scheduled
* trigger. Runs hourly so it can act anywhere inside a user's window regardless
* of the window's length. Mirrors {@link AppAutoUpdateJob}.
*/
export class ContentAutoUpdateJob {
static get queue() {
return 'system'
}
static get key() {
return 'content-auto-update'
}
async handle(_job: Job) {
logger.info('[ContentAutoUpdateJob] Evaluating content auto-updates...')
const contentAutoUpdateService = new ContentAutoUpdateService(
new DownloadService(QueueService.getInstance())
)
const result = await contentAutoUpdateService.attempt()
logger.info(`[ContentAutoUpdateJob] ${result.started} started: ${result.reason}`)
return result
}
static async schedule() {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
await queue.upsertJobScheduler(
'hourly-content-auto-update',
{ pattern: '0 * * * *' }, // Top of every hour; attempt() gates on the window
{
name: this.key,
opts: {
removeOnComplete: { count: 12 },
removeOnFail: { count: 5 },
},
}
)
logger.info('[ContentAutoUpdateJob] Content auto-update evaluation scheduled with cron: 0 * * * *')
}
static async dispatch() {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const job = await queue.add(
this.key,
{},
{
attempts: 1,
removeOnComplete: { count: 12 },
removeOnFail: { count: 5 },
}
)
logger.info(`[ContentAutoUpdateJob] Dispatched ad-hoc content auto-update evaluation job ${job.id}`)
return job
}
}

View File

@ -37,7 +37,7 @@ export class DownloadModelJob {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const client = await queue.client
await client.set(this.cancelKey(jobId), '1', 'EX', 300) // 5 min TTL
await client.set(this.cancelKey(jobId), '1', { EX: 300 }) // 5 min TTL
}
async handle(job: Job) {

View File

@ -18,6 +18,11 @@ export interface EmbedFileJobParams {
batchOffset?: number // Current batch offset (for ZIM files)
totalArticles?: number // Total articles in ZIM (for progress tracking)
isFinalBatch?: boolean // Whether this is the last batch (prevents premature deletion)
// Running total of chunks embedded across prior batches in this dispatch chain.
// Carried forward so the final batch can persist an accurate `chunks_embedded`
// count via KbIngestState.markIndexed (see #933 — without this, only the last
// batch's chunk count was stored while Qdrant held the full set).
chunksSoFar?: number
}
export class EmbedFileJob {
@ -159,18 +164,46 @@ export class EmbedFileJob {
await new Promise((resolve) => setTimeout(resolve, EmbedFileJob.CPU_BATCH_DELAY_MS))
}
// Dispatch next batch (not final yet)
// 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)
await EmbedFileJob.dispatch({
filePath,
fileName,
batchOffset: nextOffset,
totalArticles: totalArticles || result.totalArticles,
isFinalBatch: false, // Explicitly not final
chunksSoFar: chunksSoFarNext,
})
// Calculate progress based on articles processed
// Calculate progress based on articles processed.
//
// nextOffset counts entries passing our isArticleEntry() filter, but the
// denominator (totalArticles = archive.articleCount) uses libzim's
// narrower article definition. On ZIMs that pack one logical article as
// several sub-pages (e.g. iFixit), nextOffset outruns articleCount and a
// raw ratio overflows past 100%, which the UI pins at 99% for the entire
// tail so the file looks stuck (#903). Grow the denominator once we pass
// the reported count so the gauge keeps creeping forward monotonically,
// and never report 100% before the genuinely-final batch (handled below).
const progress = totalArticles
? Math.round((nextOffset / totalArticles) * 100)
? Math.min(99, Math.round((nextOffset / Math.max(totalArticles, nextOffset + ZIM_BATCH_SIZE)) * 100))
: 50
await this.safeUpdateProgress(job, progress)
@ -178,7 +211,7 @@ export class EmbedFileJob {
...job.data,
status: 'batch_completed',
lastBatchAt: Date.now(),
chunks: (job.data.chunks || 0) + (result.chunks || 0),
chunks: chunksSoFarNext,
})
return {
@ -192,8 +225,11 @@ export class EmbedFileJob {
}
}
// Final batch or non-batched file - mark as complete
const totalChunks = (job.data.chunks || 0) + (result.chunks || 0)
// Final batch or non-batched file - mark as complete.
// chunksSoFar carries the accumulated count from prior dispatched batches
// (each continuation passes it forward — see EmbedFileJobParams). For a
// non-batched file it is undefined and we just count this single result.
const totalChunks = (job.data.chunksSoFar || 0) + (result.chunks || 0)
await this.safeUpdateProgress(job, 100)
await job.updateData({
...job.data,
@ -227,23 +263,38 @@ export class EmbedFileJob {
message: `Successfully embedded ${result.chunks} chunks`,
}
} catch (error) {
logger.error(`[EmbedFileJob] Error embedding file ${fileName}:`, error)
// A chunk that still exceeds the model's context after OllamaService's truncate-and-retry is
// permanently oversized for this install (e.g. a model whose context is smaller than our safe
// cap). Re-embedding the whole file 30x re-processes everything and can never succeed — that is
// the "endless queue loop" / "api/embed for weeks" (#881/#944/#959). Mark it unrecoverable so
// BullMQ stops after one pass instead of storming.
let normalizedError = error
if (!(error instanceof UnrecoverableError) && OllamaService.isContextLengthError(error)) {
logger.warn(
`[EmbedFileJob] Context-length overflow persisted for ${fileName} after truncation; not retrying.`
)
normalizedError = new UnrecoverableError(
error instanceof Error ? error.message : 'Embedding input exceeds the model context length'
)
}
logger.error(`[EmbedFileJob] Error embedding file ${fileName}:`, normalizedError)
await job.updateData({
...job.data,
status: 'failed',
failedAt: Date.now(),
error: error instanceof Error ? error.message : 'Unknown error',
error: normalizedError instanceof Error ? normalizedError.message : 'Unknown error',
})
// Only persist `failed` for unrecoverable errors. Retryable errors get
// automatic BullMQ retries (30 attempts); marking state failed on every
// transient blip would suppress the retry-driven recovery path.
if (error instanceof UnrecoverableError) {
if (normalizedError instanceof UnrecoverableError) {
try {
await KbIngestState.markFailed(
filePath,
error instanceof Error ? error.message : 'Unknown error'
normalizedError instanceof Error ? normalizedError.message : 'Unknown error'
)
} catch (stateErr) {
logger.warn(
@ -253,7 +304,7 @@ export class EmbedFileJob {
}
}
throw error
throw normalizedError
}
}
@ -408,6 +459,46 @@ 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

@ -6,7 +6,36 @@ import { createHash } from 'crypto'
import { DockerService } from '#services/docker_service'
import { ZimService } from '#services/zim_service'
import { MapService } from '#services/map_service'
import { RagService } from '#services/rag_service'
import { OllamaService } from '#services/ollama_service'
import { EmbedFileJob } from './embed_file_job.js'
import { basename, join, resolve, sep } from 'node:path'
import { ZIM_STORAGE_PATH } from '../utils/fs.js'
/** Maps live under `<cwd>/storage/maps/pmtiles`; no shared constant exists. */
const MAP_STORAGE_PATH = '/storage/maps'
/**
* Guard for the outdated-file deletion in {@link RunDownloadJob} `onComplete`:
* returns true only when `oldFilePath` sits under the expected content storage
* root for its type AND its filename carries this resource's id prefix. This
* makes the delete explicit and bounded we only ever remove the replaced
* resource's own previous file, never another file, even if the
* InstalledResource row is stale or malformed.
*/
function isSafeOldContentPath(
oldFilePath: string,
resourceId: string,
filetype: string
): boolean {
const root =
filetype === 'zim'
? join(process.cwd(), ZIM_STORAGE_PATH)
: join(process.cwd(), MAP_STORAGE_PATH)
const resolved = resolve(oldFilePath)
if (!resolved.startsWith(root + sep)) return false
return basename(resolved).startsWith(`${resourceId}_`)
}
export class RunDownloadJob {
static get queue() {
@ -34,7 +63,7 @@ export class RunDownloadJob {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const client = await queue.client
await client.set(this.cancelKey(jobId), '1', 'EX', 300) // 5 min TTL
await client.set(this.cancelKey(jobId), '1', { EX: 300 }) // 5 min TTL
}
async handle(job: Job) {
@ -98,6 +127,11 @@ export class RunDownloadJob {
lastKnownProgress = { downloadedBytes: progress.downloadedBytes, totalBytes: progress.totalBytes }
},
async onComplete(url) {
// The previous file recorded for this resource (if any). Hoisted out of
// the metadata block below so the ZIM branch can decide whether this
// download is a content UPDATE (replacing a prior file) vs a fresh
// install, which changes how we reconcile the knowledge base.
let oldFilePath: string | null = null
try {
// Create InstalledResource entry if metadata was provided
if (resourceMetadata) {
@ -111,9 +145,9 @@ export class RunDownloadJob {
.where('resource_id', resourceMetadata.resource_id)
.where('resource_type', filetype as 'zim' | 'map')
.first()
const oldFilePath = oldEntry?.file_path ?? null
oldFilePath = oldEntry?.file_path ?? null
await InstalledResource.updateOrCreate(
const installed = await InstalledResource.updateOrCreate(
{ resource_id: resourceMetadata.resource_id, resource_type: filetype as 'zim' | 'map' },
{
version: resourceMetadata.version,
@ -125,15 +159,45 @@ export class RunDownloadJob {
}
)
// Delete the old file if it differs from the new one
if (oldFilePath && oldFilePath !== filepath) {
// A completed auto-update is the authoritative success signal for the
// per-resource backoff — clear it here (NOT at dispatch time, which
// would reset the counter every window and defeat self-disable). The
// matching terminal-failure increment lives in the worker `failed`
// handler (commands/queue/work.ts). Manual downloads (auto !== true)
// never touch the counter.
if (resourceMetadata.auto === true) {
try {
await deleteFileIfExists(oldFilePath)
console.log(`[RunDownloadJob] Deleted old file: ${oldFilePath}`)
} catch (deleteError) {
const { recordResourceUpdateSuccess } = await import(
'../utils/content_auto_update_backoff.js'
)
await recordResourceUpdateSuccess(installed)
} catch (error) {
console.error(
`[RunDownloadJob] Error clearing auto-update backoff for ${resourceMetadata.resource_id}:`,
error
)
}
}
// Step 1: delete the OUTDATED file if it differs from the new one.
// Guarded by isSafeOldContentPath so we can ONLY ever delete the
// replaced resource's own previous file — never another resource's
// file, even if the InstalledResource row is stale/malformed.
if (oldFilePath && oldFilePath !== filepath) {
if (isSafeOldContentPath(oldFilePath, resourceMetadata.resource_id, filetype)) {
try {
await deleteFileIfExists(oldFilePath)
console.log(`[RunDownloadJob] Deleted old file: ${oldFilePath}`)
} catch (deleteError) {
console.warn(
`[RunDownloadJob] Failed to delete old file ${oldFilePath}:`,
deleteError
)
}
} else {
console.warn(
`[RunDownloadJob] Failed to delete old file ${oldFilePath}:`,
deleteError
`[RunDownloadJob] Refusing to delete unexpected old file path for ` +
`${resourceMetadata.resource_id} (${filetype}): ${oldFilePath}`
)
}
}
@ -144,42 +208,76 @@ export class RunDownloadJob {
const zimService = new ZimService(dockerService)
await zimService.downloadRemoteSuccessCallback([url], true)
// Only dispatch embedding job if AI Assistant (Ollama) is installed
// Only touch the knowledge base if AI Assistant (Ollama) is installed
const ollamaUrl = await dockerService.getServiceURL('nomad_ollama')
if (ollamaUrl) {
// Respect the global ingest policy. Under Manual, record the file
// as pending_decision so the KB panel surfaces the per-file Index
// affordance (PR #909) instead of silently auto-embedding behind
// the user's back. Unset is treated as Always to preserve legacy
// behavior — mirrors rag_service.ts:1587-1588.
const { default: KVStore } = await import('#models/kv_store')
const { default: KbIngestState } = await import('#models/kb_ingest_state')
const policyRaw = await KVStore.getValue('rag.defaultIngestPolicy')
const policy: 'Always' | 'Manual' = policyRaw === 'Manual' ? 'Manual' : 'Always'
// A content UPDATE replaces a prior file at a DIFFERENT path
// (version is in the filename). A fresh install has no prior row;
// a same-version re-download keeps the same path. The two cases
// reconcile the KB differently.
const isReplacement = !!oldFilePath && oldFilePath !== filepath
if (policy === 'Manual') {
if (isReplacement) {
// CONTENT UPDATE: mirror the REPLACED file's prior indexed state
// rather than the global Always/Manual policy. reconcileReplaced-
// ContentFile removes the old file's points and re-queues the new
// file IFF the old one was indexed and Qdrant is running; it is a
// no-op otherwise (not installed / old not indexed / Qdrant down).
// The user already chose whether this content is in the KB, so we
// honor that choice in both directions. See the method for the
// full 5-step contract.
try {
// firstOrCreate so a re-download doesn't demote an existing
// indexed/failed row — user keeps prior state and can re-index
// explicitly from the KB panel if they want fresh content.
await KbIngestState.firstOrCreate(
{ file_path: filepath },
{ file_path: filepath, state: 'pending_decision', chunks_embedded: 0 }
const ragService = new RagService(dockerService, new OllamaService())
const outcome = await ragService.reconcileReplacedContentFile({
oldFilePath: oldFilePath!,
newFilePath: filepath,
fileName: url.split('/').pop() || '',
})
console.log(
`[RunDownloadJob] KB reconciliation for replaced ${filepath}: ${outcome}`
)
} catch (error) {
console.error(
`[RunDownloadJob] Error recording pending_decision state for ${filepath}:`,
`[RunDownloadJob] Error reconciling knowledge base for replaced file ${filepath}:`,
error
)
}
} else {
try {
await EmbedFileJob.dispatch({
fileName: url.split('/').pop() || '',
filePath: filepath,
})
} catch (error) {
console.error(`[RunDownloadJob] Error dispatching EmbedFileJob for URL ${url}:`, error)
// FRESH INSTALL (or same-version re-download): respect the global
// ingest policy. Under Manual, record the file as pending_decision
// so the KB panel surfaces the per-file Index affordance (PR #909)
// instead of silently auto-embedding behind the user's back. Unset
// is treated as Always to preserve legacy behavior — mirrors
// rag_service.ts:1587-1588.
const { default: KVStore } = await import('#models/kv_store')
const { default: KbIngestState } = await import('#models/kb_ingest_state')
const policyRaw = await KVStore.getValue('rag.defaultIngestPolicy')
const policy: 'Always' | 'Manual' = policyRaw === 'Manual' ? 'Manual' : 'Always'
if (policy === 'Manual') {
try {
// firstOrCreate so a re-download doesn't demote an existing
// indexed/failed row — user keeps prior state and can re-index
// explicitly from the KB panel if they want fresh content.
await KbIngestState.firstOrCreate(
{ file_path: filepath },
{ file_path: filepath, state: 'pending_decision', chunks_embedded: 0 }
)
} catch (error) {
console.error(
`[RunDownloadJob] Error recording pending_decision state for ${filepath}:`,
error
)
}
} else {
try {
await EmbedFileJob.dispatch({
fileName: url.split('/').pop() || '',
filePath: filepath,
})
} catch (error) {
console.error(`[RunDownloadJob] Error dispatching EmbedFileJob for URL ${url}:`, error)
}
}
}
}

View File

@ -52,7 +52,7 @@ export class RunExtractPmtilesJob {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const client = await queue.client
await client.set(this.cancelKey(jobId), '1', 'EX', 300)
await client.set(this.cancelKey(jobId), '1', { EX: 300 })
}
/** Awaits job.updateProgress and swallows BullMQ stale-job errors (code -1),

View File

@ -30,4 +30,25 @@ export default class InstalledResource extends BaseModel {
@column.dateTime()
declare installed_at: DateTime
// ── Content auto-update state (global opt-in; gated by `contentAutoUpdate.enabled`) ──
/** Newest catalog version (YYYY-MM) detected, or null when already current. */
@column()
declare available_update_version: string | null
/** Size (bytes) of the available update, captured from the catalog. */
@column()
declare available_update_size_bytes: number | null
/** Cool-off anchor: when the current available update was first detected. */
@column.dateTime()
declare available_update_first_seen_at: DateTime | null
/** Per-resource failure backoff so one flapping download self-disables. */
@column()
declare auto_update_consecutive_failures: number
@column()
declare auto_update_disabled_reason: string | null
}

View File

@ -49,10 +49,22 @@ export default class KbRatioRegistry extends BaseModel {
return findChunksPerMb(filename, rows)
}
/** Estimate total chunks for a file of the given size on disk. */
static async estimateChunks(filename: string, fileSizeBytes: number): Promise<number | null> {
/**
* Estimate total chunks for a file of the given size on disk.
*
* `ignoreCatchAll` excludes the empty-pattern fallback, returning `null` for
* filenames that only the catch-all would match. The partial_stall warning
* uses this so it never flags ZIMs the registry can't specifically
* characterize (e.g. PDF/link-out-heavy archives whose byte size wildly
* over-predicts embeddable chunks). See #913.
*/
static async estimateChunks(
filename: string,
fileSizeBytes: number,
opts: { ignoreCatchAll?: boolean } = {}
): Promise<number | null> {
const rows = await this.all()
return estimateChunkCount(filename, fileSizeBytes, rows)
return estimateChunkCount(filename, fileSizeBytes, rows, opts)
}
/**

View File

@ -59,9 +59,42 @@ export default class Service extends BaseModel {
@column()
declare ui_location: string | null
// User-set override for the launch ("Open") link (e.g. a reverse-proxy/local-DNS host like
// https://jellyfin.myhomelab.net). When null, the default host + port link derived from
// ui_location is used. Only affects user-facing links — never internal service-to-service URLs.
@column()
declare custom_url: string | null
@column()
declare metadata: string | null
@column({
serialize(value) {
return Boolean(value)
},
})
declare is_custom: boolean
@column({
serialize(value) {
return Boolean(value)
},
})
declare is_user_modified: boolean
@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
@ -71,6 +104,28 @@ export default class Service extends BaseModel {
@column.dateTime()
declare update_checked_at: DateTime | null
// Per-app opt-in for automatic updates. An app auto-updates only when both this
// and the global `appAutoUpdate.enabled` master switch are on.
@column({
serialize(value) {
return Boolean(value)
},
})
declare auto_update_enabled: boolean
// When the current `available_update_version` was first detected — the anchor for
// the auto-update cool-off (registry tags carry no publish timestamp).
@column.dateTime()
declare available_update_first_seen_at: DateTime | null
// Per-app auto-update failure backoff; at the threshold the app self-disables via
// `auto_update_disabled_reason` without affecting other apps.
@column()
declare auto_update_consecutive_failures: number
@column()
declare auto_update_disabled_reason: string | null
@column.dateTime({ autoCreate: true })
declare created_at: DateTime

View File

@ -0,0 +1,398 @@
import { inject } from '@adonisjs/core'
import logger from '@adonisjs/core/services/logger'
import { DateTime } from 'luxon'
import KVStore from '#models/kv_store'
import Service from '#models/service'
import { DockerService } from '#services/docker_service'
import { DownloadService } from '#services/download_service'
import { SystemService } from '#services/system_service'
import { ContainerRegistryService } from '#services/container_registry_service'
import { isNewerVersion, parseMajorVersion } from '../utils/version.js'
import { isWithinWindow } from '../utils/update_window.js'
import {
checkImageDiskSpace,
type Blocker,
type PreflightResult,
} from '../utils/image_disk_preflight.js'
/**
* Defaults shared with the core auto-update. App auto-updates intentionally reuse
* the SAME window/cool-off settings (`autoUpdate.windowStart/windowEnd/cooloffHours`);
* only the enable flag (`appAutoUpdate.enabled`) is separate.
*/
const DEFAULT_WINDOW_START = '02:00'
const DEFAULT_WINDOW_END = '05:00'
const DEFAULT_COOLOFF_HOURS = 72
/** Per-app genuine failures before that app self-disables (others keep running). */
const MAX_CONSECUTIVE_FAILURES = 3
export interface AppAutoUpdateConfig {
/** Global master switch (`appAutoUpdate.enabled`). */
enabled: boolean
windowStart: string
windowEnd: string
cooloffHours: number
}
/** An installed app that should be auto-updated this run. */
export interface AppUpdateTarget {
service: Service
/** Exact registry tag to update to (the value in `available_update_version`). */
targetVersion: string
}
/** Per-app eligibility verdict (drives both selection and the status UI). */
export interface AppEligibility {
eligible: boolean
reason: string
cooloffRemainingHours: number | null
}
export interface AppAutoUpdateAppStatus {
service_name: string
friendly_name: string | null
auto_update_enabled: boolean
current_version: string
available_update_version: string | null
first_seen_at: string | null
eligible: boolean
reason: string
cooloff_remaining_hours: number | null
consecutive_failures: number
auto_disabled_reason: string | null
}
export interface AppAutoUpdateStatus extends AppAutoUpdateConfig {
withinWindow: boolean
lastAttemptAt: string | null
lastResult: string | null
apps: AppAutoUpdateAppStatus[]
}
/**
* Decision + safety layer for automatic updates of installed sibling apps (the
* containers NOMAD deploys via the Docker socket and manages in Supply Depot).
*
* This is the app-side counterpart to {@link AutoUpdateService} and intentionally
* reuses its generic window/disk pre-flight helpers. Unlike the core update, an
* app update needs no sidecar the admin container recreates its siblings directly
* via {@link DockerService.updateContainer} (in-process pull rename health-check
* rollback). Auto-update only decides *whether* each opted-in app should update now
* (master switch on + per-app toggle on + in window + an eligible minor/patch past
* its cool-off + pre-flight passes) and then drives the existing update path.
*
* Minor/patch-only is already guaranteed upstream by
* {@link ContainerRegistryService.getAvailableUpdates} (same-major filter); the
* major-version check here is defense-in-depth.
*/
@inject()
export class AppAutoUpdateService {
constructor(
private dockerService: DockerService,
private downloadService: DownloadService,
private systemService: SystemService,
private containerRegistryService: ContainerRegistryService
) {}
/** Read the global master switch plus the shared window/cool-off settings. */
async getConfig(): Promise<AppAutoUpdateConfig> {
const [enabled, windowStart, windowEnd, cooloffHours] = await Promise.all([
KVStore.getValue('appAutoUpdate.enabled'),
KVStore.getValue('autoUpdate.windowStart'),
KVStore.getValue('autoUpdate.windowEnd'),
KVStore.getValue('autoUpdate.cooloffHours'),
])
const parsedCooloff = Number(cooloffHours)
return {
enabled: enabled ?? false,
windowStart: windowStart || DEFAULT_WINDOW_START,
windowEnd: windowEnd || DEFAULT_WINDOW_END,
// `Number(null) === 0`, so an unset value must fall through to the default
// rather than silently resolving to a zero cool-off. An explicit 0 is honored.
cooloffHours:
cooloffHours !== null && Number.isFinite(parsedCooloff) && parsedCooloff >= 0
? parsedCooloff
: DEFAULT_COOLOFF_HOURS,
}
}
/**
* Pure per-app eligibility verdict. An app is eligible when it has a detected
* update that is the same major (defense-in-depth), strictly newer, not self-
* disabled, and past its cool-off (measured from first-detected).
*/
appEligibility(service: Service, cooloffHours: number, now: DateTime): AppEligibility {
if (!service.available_update_version) {
return { eligible: false, reason: 'Up to date', cooloffRemainingHours: null }
}
if (service.auto_update_disabled_reason) {
return {
eligible: false,
reason: 'Auto-update disabled after repeated failures',
cooloffRemainingHours: null,
}
}
const currentTag = this.containerRegistryService.parseImageReference(
service.container_image
).tag
if (currentTag === 'latest') {
return {
eligible: false,
reason: 'Pinned to :latest — cannot version-check',
cooloffRemainingHours: null,
}
}
if (parseMajorVersion(service.available_update_version) !== parseMajorVersion(currentTag)) {
return {
eligible: false,
reason: 'Major version — manual update required',
cooloffRemainingHours: null,
}
}
if (!isNewerVersion(service.available_update_version, currentTag)) {
return { eligible: false, reason: 'Up to date', cooloffRemainingHours: null }
}
if (!service.available_update_first_seen_at) {
return { eligible: false, reason: 'Cool-off pending', cooloffRemainingHours: cooloffHours }
}
const ageHours = now.diff(service.available_update_first_seen_at, 'hours').hours
const remaining = cooloffHours - ageHours
if (remaining > 0) {
const rounded = Math.ceil(remaining)
return {
eligible: false,
reason: `In cool-off (${rounded}h remaining)`,
cooloffRemainingHours: rounded,
}
}
return {
eligible: true,
reason: `Eligible → ${service.available_update_version}`,
cooloffRemainingHours: 0,
}
}
/** Installed, opted-in apps that are eligible to update right now. */
async getEligibleApps(config: AppAutoUpdateConfig, now: DateTime): Promise<AppUpdateTarget[]> {
const apps = await Service.query().where('installed', true).where('auto_update_enabled', true)
const targets: AppUpdateTarget[] = []
for (const service of apps) {
const verdict = this.appEligibility(service, config.cooloffHours, now)
if (verdict.eligible) {
targets.push({ service, targetVersion: service.available_update_version! })
}
}
return targets
}
/**
* Run-wide pre-flight checked once per attempt (independent of any single app):
* never auto-update while content/model downloads are running. Transient `skip`.
*/
async runGlobalPreflight(): Promise<PreflightResult> {
const blockers: Blocker[] = []
try {
const downloads = await this.downloadService.listDownloadJobs()
const active = downloads.filter(
(d) => !!d.status && ['waiting', 'active', 'delayed'].includes(d.status)
)
if (active.length > 0) {
blockers.push({ reason: `${active.length} download(s) in progress`, severity: 'skip' })
}
} catch (error) {
logger.warn(`[AppAutoUpdateService] Could not check active downloads: ${error.message}`)
}
return { ok: blockers.length === 0, blockers }
}
/** Per-app pre-flight: not already mid-operation (`skip`) and enough disk (`failure`). */
async runAppPreflight(target: AppUpdateTarget): Promise<PreflightResult> {
const blockers: Blocker[] = []
const service = target.service
if (service.installation_status !== 'idle') {
blockers.push({
reason: `App has an operation in progress (status: ${service.installation_status})`,
severity: 'skip',
})
}
const hostArch = await this.getHostArch()
const targetImage = `${this.imageBase(service.container_image)}:${target.targetVersion}`
const diskBlocker = await checkImageDiskSpace({
image: targetImage,
hostArch,
containerRegistryService: this.containerRegistryService,
systemService: this.systemService,
})
if (diskBlocker) blockers.push(diskBlocker)
return { ok: blockers.length === 0, blockers }
}
/**
* Entry point invoked by AppAutoUpdateJob. Gates on the master switch + window,
* then runs each eligible app through pre-flight and {@link DockerService.updateContainer}.
* A failing app self-disables after repeated failures without affecting the others.
*/
async attempt(): Promise<{ updated: number; reason: string }> {
const config = await this.getConfig()
const now = DateTime.now()
if (!config.enabled) {
return { updated: 0, reason: 'App auto-update is disabled' }
}
if (!isWithinWindow(config.windowStart, config.windowEnd, now)) {
const reason = `Outside update window (${config.windowStart}-${config.windowEnd})`
await this.recordRun(reason)
return { updated: 0, reason }
}
const eligible = await this.getEligibleApps(config, now)
if (eligible.length === 0) {
const reason = 'No eligible app updates (all current, in cool-off, or major-only)'
await this.recordRun(reason)
return { updated: 0, reason }
}
const global = await this.runGlobalPreflight()
if (!global.ok) {
const reason = `Pre-flight blocked: ${global.blockers.map((b) => b.reason).join('; ')}`
await this.recordRun(reason)
return { updated: 0, reason }
}
let updated = 0
let failed = 0
let skipped = 0
for (const target of eligible) {
const name = target.service.service_name
const preflight = await this.runAppPreflight(target)
if (!preflight.ok) {
const summary = preflight.blockers.map((b) => b.reason).join('; ')
if (preflight.blockers.some((b) => b.severity === 'failure')) {
await this.recordAppFailure(target.service, summary)
failed++
} else {
logger.info(`[AppAutoUpdateService] Skipped ${name}: ${summary}`)
skipped++
}
continue
}
logger.info(`[AppAutoUpdateService] Updating ${name}${target.targetVersion}`)
const result = await this.dockerService.updateContainer(name, target.targetVersion)
if (result.success) {
await this.recordAppSuccess(target.service)
updated++
} else {
await this.recordAppFailure(target.service, result.message)
failed++
}
}
const reason = `${updated} updated, ${failed} failed, ${skipped} skipped`
await this.recordRun(reason)
logger.info(`[AppAutoUpdateService] Run complete: ${reason}`)
return { updated, reason }
}
/** Clear an app's failure backoff after a successful auto-update. */
private async recordAppSuccess(service: Service): Promise<void> {
// updateContainer already advanced container_image and cleared
// available_update_version on its own (fresh) row; here we only touch the
// backoff fields, so Lucid persists just those dirty columns.
service.auto_update_consecutive_failures = 0
service.auto_update_disabled_reason = null
await service.save()
}
/** Record an app failure and self-disable it once the threshold is reached. */
private async recordAppFailure(service: Service, reason: string): Promise<void> {
const failures = (service.auto_update_consecutive_failures || 0) + 1
service.auto_update_consecutive_failures = failures
if (failures >= MAX_CONSECUTIVE_FAILURES) {
service.auto_update_disabled_reason = `Auto-update disabled after ${failures} consecutive failures. Last error: ${reason}`
logger.error(
`[AppAutoUpdateService] ${service.service_name} auto-disabled after ${failures} failures`
)
}
await service.save()
logger.error(
`[AppAutoUpdateService] ${service.service_name} failure ${failures}/${MAX_CONSECUTIVE_FAILURES}: ${reason}`
)
}
/** Record the global last-attempt summary for the settings UI. */
private async recordRun(reason: string): Promise<void> {
await KVStore.setValue('appAutoUpdate.lastAttemptAt', DateTime.now().toISO()!)
await KVStore.setValue('appAutoUpdate.lastResult', reason)
}
/** Full state snapshot for the settings UI (opted-in apps + their eligibility). */
async getStatus(): Promise<AppAutoUpdateStatus> {
const config = await this.getConfig()
const now = DateTime.now()
const apps = await Service.query().where('installed', true).where('auto_update_enabled', true)
const appStatuses: AppAutoUpdateAppStatus[] = apps.map((service) => {
const verdict = this.appEligibility(service, config.cooloffHours, now)
return {
service_name: service.service_name,
friendly_name: service.friendly_name,
auto_update_enabled: service.auto_update_enabled,
current_version: this.containerRegistryService.parseImageReference(service.container_image)
.tag,
available_update_version: service.available_update_version,
first_seen_at: service.available_update_first_seen_at?.toISO() ?? null,
eligible: verdict.eligible,
reason: verdict.reason,
cooloff_remaining_hours: verdict.cooloffRemainingHours,
consecutive_failures: service.auto_update_consecutive_failures || 0,
auto_disabled_reason: service.auto_update_disabled_reason,
}
})
const [lastAttemptAt, lastResult] = await Promise.all([
KVStore.getValue('appAutoUpdate.lastAttemptAt'),
KVStore.getValue('appAutoUpdate.lastResult'),
])
return {
...config,
withinWindow: isWithinWindow(config.windowStart, config.windowEnd, now),
lastAttemptAt: lastAttemptAt || null,
lastResult: lastResult || null,
apps: appStatuses,
}
}
/** Strip the tag from an image reference, leaving "registry/namespace/repo". */
private imageBase(image: string): string {
return image.includes(':') ? image.substring(0, image.lastIndexOf(':')) : image
}
/** Map the Docker daemon's architecture string to OCI naming (amd64/arm64/...). */
private async getHostArch(): Promise<string> {
try {
const info = await this.dockerService.docker.info()
const arch = info.Architecture || ''
const archMap: Record<string, string> = {
x86_64: 'amd64',
aarch64: 'arm64',
armv7l: 'arm',
amd64: 'amd64',
arm64: 'arm64',
}
return archMap[arch] || arch.toLowerCase()
} catch {
return 'amd64'
}
}
}

View File

@ -0,0 +1,580 @@
import { inject } from '@adonisjs/core'
import logger from '@adonisjs/core/services/logger'
import axios from 'axios'
import { DateTime } from 'luxon'
import KVStore from '#models/kv_store'
import Service from '#models/service'
import { DockerService } from '#services/docker_service'
import { DownloadService } from '#services/download_service'
import { SystemService } from '#services/system_service'
import { SystemUpdateService } from '#services/system_update_service'
import { ContainerRegistryService } from '#services/container_registry_service'
import { isNewerVersion, parseMajorVersion } from '../utils/version.js'
import { isWithinWindow as isWithinWindowUtil } from '../utils/update_window.js'
import {
checkImageDiskSpace,
type Blocker,
type PreflightResult,
} from '../utils/image_disk_preflight.js'
/** Docker image repository for the NOMAD admin/core image (tag applied per-release). */
const NOMAD_IMAGE_REPO = 'ghcr.io/crosstalk-solutions/project-nomad'
const RELEASES_URL = 'https://api.github.com/repos/Crosstalk-Solutions/project-nomad/releases'
/** Defaults for user-configurable settings (server-local time window + cool-off). */
const DEFAULT_WINDOW_START = '02:00'
const DEFAULT_WINDOW_END = '05:00'
const DEFAULT_COOLOFF_HOURS = 72
/** Genuine failures before auto-update disables itself to avoid an update loop. */
const MAX_CONSECUTIVE_FAILURES = 3
/**
* Only tags matching strict semver are eligible. Defense-in-depth: the selected
* tag becomes `target_tag`, which the sidecar interpolates into a host-side `sed`
* (install/sidecar-updater/update-watcher.sh) so a malformed tag must never be
* able to reach it, even though releases come from a trusted repo.
*/
const SEMVER_TAG = /^\d+\.\d+\.\d+$/
/** Cache the GitHub releases feed in-process to avoid hammering the API. */
const RELEASES_CACHE_TTL_MS = 15 * 60 * 1000
/** Briefly remember a failed fetch so repeated calls don't each block on the timeout. */
const RELEASES_FAILURE_TTL_MS = 60 * 1000
export interface AutoUpdateConfig {
enabled: boolean
windowStart: string
windowEnd: string
cooloffHours: number
}
export interface EligibleTarget {
version: string
tag: string
publishedAt: string
}
// Pre-flight types/primitives are shared with AppAutoUpdateService; re-exported
// here for back-compat with existing imports of this module.
export type { Blocker, BlockerSeverity, PreflightResult } from '../utils/image_disk_preflight.js'
/** Minimal shape of a GitHub release entry we depend on. */
export interface GithubRelease {
tag_name?: string
published_at?: string
draft?: boolean
prerelease?: boolean
}
/**
* Inputs that can be injected to exercise the decision pipeline deterministically
* (used by the dry-run command/tests). All are optional; when omitted the real
* settings/clock/GitHub feed/pre-flight are used, exactly as production runs.
*/
export interface EvaluateOverrides {
currentVersion?: string
releases?: GithubRelease[]
now?: DateTime
forceEnabled?: boolean
windowStart?: string
windowEnd?: string
cooloffHours?: number
/** Treat pre-flight as passing without touching Docker/disk/queues. */
skipPreflight?: boolean
/** Substitute a canned pre-flight result. */
fakePreflight?: PreflightResult
}
export type DecisionOutcome =
| 'disabled'
| 'outside-window'
| 'eligibility-error'
| 'no-eligible'
| 'blocked'
| 'ready'
/** Side-effect-free verdict of the decision pipeline. */
export interface AutoUpdateDecision {
enabled: boolean
currentVersion: string
config: AutoUpdateConfig
withinWindow: boolean
eligibleTarget: EligibleTarget | null
preflight: PreflightResult | null
outcome: DecisionOutcome
reason: string
}
export interface AutoUpdateStatus extends AutoUpdateConfig {
currentVersion: string
withinWindow: boolean
eligibleTarget: EligibleTarget | null
lastAttemptAt: string | null
lastResult: string | null
lastError: string | null
consecutiveFailures: number
autoDisabledReason: string | null
}
/**
* Decision + safety layer for automatic updates of the NOMAD application itself.
*
* It does NOT recreate containers that remains the sidecar's job. This service
* decides *whether* an update should run right now (opt-in, in-window, an eligible
* minor/patch release exists past its cool-off, pre-flight checks pass) and, if so,
* drives the existing {@link SystemUpdateService.requestUpdate} with an explicit,
* eligibility-vetted image tag.
*
* The window/pre-flight helpers are intentionally generic so a future PR can reuse
* them to auto-update installed apps (driving DockerService.updateContainer instead).
*/
@inject()
export class AutoUpdateService {
constructor(
private dockerService: DockerService,
private downloadService: DownloadService,
private systemService: SystemService,
private systemUpdateService: SystemUpdateService,
private containerRegistryService: ContainerRegistryService
) {}
/** In-process cache of the last successful releases fetch (per-process). */
private static releasesCache: { releases: GithubRelease[]; at: number } | null = null
/** Timestamp of the last failed fetch, for short-lived negative caching. */
private static releasesFailureAt = 0
/** Read user-configurable settings, applying defaults. */
async getConfig(): Promise<AutoUpdateConfig> {
const [enabled, windowStart, windowEnd, cooloffHours] = await Promise.all([
KVStore.getValue('autoUpdate.enabled'),
KVStore.getValue('autoUpdate.windowStart'),
KVStore.getValue('autoUpdate.windowEnd'),
KVStore.getValue('autoUpdate.cooloffHours'),
])
const parsedCooloff = Number(cooloffHours)
return {
enabled: enabled ?? false,
windowStart: windowStart || DEFAULT_WINDOW_START,
windowEnd: windowEnd || DEFAULT_WINDOW_END,
// `Number(null) === 0`, so an *unset* value must fall through to the default
// rather than silently resolving to a zero cool-off. An explicit 0 is honored.
cooloffHours:
cooloffHours != null && Number.isFinite(parsedCooloff) && parsedCooloff >= 0
? parsedCooloff
: DEFAULT_COOLOFF_HOURS,
}
}
/**
* Determine whether `now` falls inside the configured update window. The window
* is interpreted in the container's local time (set via the TZ env var). Windows
* that wrap past midnight (start > end, e.g. 22:00-02:00) are handled.
*/
isWithinWindow(config: AutoUpdateConfig, now: DateTime = DateTime.now()): boolean {
return isWithinWindowUtil(config.windowStart, config.windowEnd, now)
}
/**
* Find the newest release that is safe to auto-apply: same major version as the
* running build (major bumps are deliberately left for manual update), strictly
* newer than current, and published at least `cooloffHours` ago. Prereleases and
* drafts are ignored auto-update never rides early access.
*
* Returns null when nothing qualifies (e.g. only a major bump is newer, or the
* newest eligible release is still inside its cool-off window).
*/
async getEligibleTarget(config: AutoUpdateConfig): Promise<EligibleTarget | null> {
const releases = await this.fetchReleases()
return this.selectEligibleTarget(
releases,
SystemService.getAppVersion(),
config.cooloffHours,
DateTime.now()
)
}
/**
* Fetch the published GitHub releases for the NOMAD repo, cached in-process.
* A successful result is reused for {@link RELEASES_CACHE_TTL_MS} so repeated
* status-page loads don't each hit (and risk rate-limiting) the unauthenticated
* GitHub API. A recent failure is negatively cached for {@link RELEASES_FAILURE_TTL_MS}
* so back-to-back calls while offline don't each block on the request timeout.
*/
async fetchReleases(): Promise<GithubRelease[]> {
const now = Date.now()
const cached = AutoUpdateService.releasesCache
if (cached && now - cached.at < RELEASES_CACHE_TTL_MS) {
return cached.releases
}
if (now - AutoUpdateService.releasesFailureAt < RELEASES_FAILURE_TTL_MS) {
throw new Error('GitHub releases fetch recently failed; backing off')
}
try {
const response = await axios.get(RELEASES_URL, {
headers: { Accept: 'application/vnd.github+json' },
timeout: 5000,
})
if (!Array.isArray(response.data)) {
throw new Error('Unexpected response from GitHub releases API')
}
AutoUpdateService.releasesCache = { releases: response.data, at: now }
return response.data
} catch (error) {
AutoUpdateService.releasesFailureAt = now
throw error
}
}
/**
* Pure selection of the newest auto-applicable release from a release list.
* Extracted so the dry-run command and tests can drive it with fixtures.
* Same major as `currentVersion`, strictly newer, published on/before
* `now - cooloffHours`, prereleases/drafts excluded. Returns null for dev
* builds or when nothing qualifies.
*/
selectEligibleTarget(
releases: GithubRelease[],
currentVersion: string,
cooloffHours: number,
now: DateTime
): EligibleTarget | null {
if (currentVersion === 'dev' || currentVersion === '0.0.0') {
return null
}
const currentMajor = parseMajorVersion(currentVersion)
const cutoff = now.minus({ hours: cooloffHours })
const candidates = releases
.filter((r) => r && !r.draft && !r.prerelease && r.tag_name && r.published_at)
.map((r) => ({
version: String(r.tag_name).replace(/^v/, '').trim(),
publishedAt: String(r.published_at),
}))
.filter((r) => SEMVER_TAG.test(r.version))
.filter((r) => parseMajorVersion(r.version) === currentMajor)
.filter((r) => isNewerVersion(r.version, currentVersion))
.filter((r) => DateTime.fromISO(r.publishedAt) <= cutoff)
.sort((a, b) => (isNewerVersion(a.version, b.version) ? -1 : 1))
const best = candidates[0]
if (!best) return null
return {
version: best.version,
tag: `v${best.version}`,
publishedAt: best.publishedAt,
}
}
/**
* Pre-flight checks that gate an auto-update. `skip` blockers are transient
* (retry next window, no penalty); `failure` blockers count toward the backoff
* that eventually auto-disables auto-update.
*/
async runPreflight(targetTag: string): Promise<PreflightResult> {
const blockers: Blocker[] = []
// 1. Sidecar must be present to perform the update.
if (!this.systemUpdateService.isSidecarAvailable()) {
blockers.push({ reason: 'Update sidecar is not available', severity: 'failure' })
}
// 2. No system update already running.
const updateStatus = this.systemUpdateService.getUpdateStatus()
if (updateStatus && !['idle', 'complete', 'error'].includes(updateStatus.stage)) {
blockers.push({
reason: `A system update is already in progress (stage: ${updateStatus.stage})`,
severity: 'skip',
})
}
// 3. No content/model downloads in progress.
try {
const downloads = await this.downloadService.listDownloadJobs()
const active = downloads.filter(
(d) => !!d.status && ['waiting', 'active', 'delayed'].includes(d.status)
)
if (active.length > 0) {
blockers.push({
reason: `${active.length} download(s) in progress`,
severity: 'skip',
})
}
} catch (error) {
logger.warn(`[AutoUpdateService] Could not check active downloads: ${error.message}`)
}
// 4. No app (container) install/update in progress.
try {
const installing = await Service.query().whereNot('installation_status', 'idle')
if (installing.length > 0) {
blockers.push({
reason: `${installing.length} app install/update(s) in progress`,
severity: 'skip',
})
}
} catch (error) {
logger.warn(`[AutoUpdateService] Could not check app installations: ${error.message}`)
}
// 5. Sufficient host storage for the new image.
const diskBlocker = await this.checkDiskSpace(targetTag)
if (diskBlocker) blockers.push(diskBlocker)
return { ok: blockers.length === 0, blockers }
}
/** Returns a disk blocker if free space is insufficient, otherwise null. */
private async checkDiskSpace(targetTag: string): Promise<Blocker | null> {
const hostArch = await this.getHostArch()
return checkImageDiskSpace({
image: `${NOMAD_IMAGE_REPO}:${targetTag}`,
hostArch,
containerRegistryService: this.containerRegistryService,
systemService: this.systemService,
})
}
/** Map the Docker daemon's architecture string to OCI naming (amd64/arm64/...). */
private async getHostArch(): Promise<string> {
try {
const info = await this.dockerService.docker.info()
const arch = info.Architecture || ''
const archMap: Record<string, string> = {
x86_64: 'amd64',
aarch64: 'arm64',
armv7l: 'arm',
amd64: 'amd64',
arm64: 'arm64',
}
return archMap[arch] || arch.toLowerCase()
} catch {
return 'amd64'
}
}
/**
* Side-effect-free core of the decision pipeline. Resolves the effective config
* (settings, overridable), checks the window, finds an eligible target, and runs
* pre-flight returning a verdict WITHOUT requesting an update or mutating any
* persisted state. Both {@link attempt} (production) and {@link dryRun} (testing)
* are built on this so a dry run faithfully reflects what a real run would do.
*/
async evaluate(overrides: EvaluateOverrides = {}): Promise<AutoUpdateDecision> {
const baseConfig = await this.getConfig()
const config: AutoUpdateConfig = {
enabled: overrides.forceEnabled ?? baseConfig.enabled,
windowStart: overrides.windowStart ?? baseConfig.windowStart,
windowEnd: overrides.windowEnd ?? baseConfig.windowEnd,
cooloffHours: overrides.cooloffHours ?? baseConfig.cooloffHours,
}
const now = overrides.now ?? DateTime.now()
const currentVersion = overrides.currentVersion ?? SystemService.getAppVersion()
const base = {
enabled: config.enabled,
currentVersion,
config,
withinWindow: false,
eligibleTarget: null as EligibleTarget | null,
preflight: null as PreflightResult | null,
}
if (!config.enabled) {
return { ...base, outcome: 'disabled', reason: 'Auto-update is disabled' }
}
const withinWindow = this.isWithinWindow(config, now)
if (!withinWindow) {
return {
...base,
outcome: 'outside-window',
reason: `Outside update window (${config.windowStart}-${config.windowEnd})`,
}
}
let eligibleTarget: EligibleTarget | null
try {
const releases = overrides.releases ?? (await this.fetchReleases())
eligibleTarget = this.selectEligibleTarget(releases, currentVersion, config.cooloffHours, now)
} catch (error) {
return {
...base,
withinWindow,
outcome: 'eligibility-error',
reason: `Failed to determine eligible version: ${error.message}`,
}
}
if (!eligibleTarget) {
return {
...base,
withinWindow,
outcome: 'no-eligible',
reason: 'No eligible minor/patch update available (or still in cool-off)',
}
}
const preflight = overrides.fakePreflight
? overrides.fakePreflight
: overrides.skipPreflight
? { ok: true, blockers: [] }
: await this.runPreflight(eligibleTarget.tag)
if (!preflight.ok) {
const summary = preflight.blockers.map((b) => b.reason).join('; ')
return {
...base,
withinWindow,
eligibleTarget,
preflight,
outcome: 'blocked',
reason: `Pre-flight blocked: ${summary}`,
}
}
return {
...base,
withinWindow,
eligibleTarget,
preflight,
outcome: 'ready',
reason: `Ready to update to ${eligibleTarget.tag}`,
}
}
/**
* Run the full decision pipeline WITHOUT requesting an update or recording any
* state. Accepts the same injectable overrides as {@link evaluate}, so callers can
* simulate any scenario (a given current version, a canned release list, a fixed
* clock, a forced window) and see exactly what a real run would decide.
*/
async dryRun(overrides: EvaluateOverrides = {}): Promise<AutoUpdateDecision> {
return this.evaluate(overrides)
}
/**
* The entry point invoked by AutoUpdateJob. Evaluates the decision pipeline and,
* when everything passes, requests the update with the vetted tag recording the
* outcome to the KVStore (for the UI) and applying failure backoff.
*/
async attempt(): Promise<{ updated: boolean; reason: string }> {
const decision = await this.evaluate()
switch (decision.outcome) {
case 'disabled':
return { updated: false, reason: decision.reason }
case 'outside-window':
case 'no-eligible':
// A failed release lookup is transient (offline-first appliances are
// routinely without connectivity) — treat as a skip so it never trips the
// backoff that auto-disables the feature. Only real update-request failures
// (the `ready` case below) count toward MAX_CONSECUTIVE_FAILURES.
case 'eligibility-error':
await this.recordSkip(decision.reason)
return { updated: false, reason: decision.reason }
case 'blocked': {
const hasFailure = decision.preflight!.blockers.some((b) => b.severity === 'failure')
if (hasFailure) {
await this.recordFailure(decision.reason)
} else {
await this.recordSkip(decision.reason)
}
return { updated: false, reason: decision.reason }
}
case 'ready': {
const target = decision.eligibleTarget!
const result = await this.systemUpdateService.requestUpdate({
targetTag: target.tag,
requester: 'auto-update',
})
if (result.success) {
await this.recordSuccess(target)
logger.info(`[AutoUpdateService] Auto-update requested: ${target.tag}`)
return { updated: true, reason: `Update requested: ${target.tag}` }
}
await this.recordFailure(`Update request failed: ${result.message}`)
return { updated: false, reason: result.message }
}
}
}
// --- Outcome recording -----------------------------------------------------
private async recordSuccess(target: EligibleTarget): Promise<void> {
await KVStore.setValue('autoUpdate.lastAttemptAt', DateTime.now().toISO()!)
await KVStore.setValue('autoUpdate.lastResult', `Update requested: ${target.tag}`)
await KVStore.clearValue('autoUpdate.lastError')
await KVStore.setValue('autoUpdate.consecutiveFailures', '0')
}
private async recordSkip(reason: string): Promise<void> {
await KVStore.setValue('autoUpdate.lastAttemptAt', DateTime.now().toISO()!)
await KVStore.setValue('autoUpdate.lastResult', reason)
logger.info(`[AutoUpdateService] Skipped: ${reason}`)
}
private async recordFailure(reason: string): Promise<void> {
await KVStore.setValue('autoUpdate.lastAttemptAt', DateTime.now().toISO()!)
await KVStore.setValue('autoUpdate.lastResult', reason)
await KVStore.setValue('autoUpdate.lastError', reason)
const prior = Number(await KVStore.getValue('autoUpdate.consecutiveFailures')) || 0
const failures = prior + 1
await KVStore.setValue('autoUpdate.consecutiveFailures', String(failures))
logger.error(`[AutoUpdateService] Failure ${failures}/${MAX_CONSECUTIVE_FAILURES}: ${reason}`)
if (failures >= MAX_CONSECUTIVE_FAILURES) {
await KVStore.setValue('autoUpdate.enabled', false)
await KVStore.setValue(
'autoUpdate.autoDisabledReason',
`Auto-update disabled after ${failures} consecutive failures. Last error: ${reason}`
)
logger.error(
`[AutoUpdateService] Auto-update auto-disabled after ${failures} consecutive failures`
)
}
}
/** Full state snapshot for the settings UI. */
async getStatus(): Promise<AutoUpdateStatus> {
const config = await this.getConfig()
const currentVersion = SystemService.getAppVersion()
let eligibleTarget: EligibleTarget | null = null
try {
eligibleTarget = await this.getEligibleTarget(config)
} catch (error) {
logger.warn(`[AutoUpdateService] getStatus eligibility lookup failed: ${error.message}`)
}
const [lastAttemptAt, lastResult, lastError, consecutiveFailures, autoDisabledReason] =
await Promise.all([
KVStore.getValue('autoUpdate.lastAttemptAt'),
KVStore.getValue('autoUpdate.lastResult'),
KVStore.getValue('autoUpdate.lastError'),
KVStore.getValue('autoUpdate.consecutiveFailures'),
KVStore.getValue('autoUpdate.autoDisabledReason'),
])
return {
...config,
currentVersion,
withinWindow: this.isWithinWindow(config),
eligibleTarget,
lastAttemptAt: lastAttemptAt || null,
lastResult: lastResult || null,
lastError: lastError || null,
consecutiveFailures: Number(consecutiveFailures) || 0,
autoDisabledReason: autoDisabledReason || null,
}
}
}

View File

@ -614,8 +614,7 @@ export class BenchmarkService {
await this.dockerService.docker.getImage(SYSBENCH_IMAGE).inspect()
} catch {
this._updateStatus('starting', `Pulling sysbench image...`)
const pullStream = await this.dockerService.docker.pull(SYSBENCH_IMAGE)
await new Promise((resolve) => this.dockerService.docker.modem.followProgress(pullStream, resolve))
await this.dockerService.pullImage(SYSBENCH_IMAGE)
}
}

View File

@ -1,5 +1,6 @@
import ChatSession from '#models/chat_session'
import ChatMessage from '#models/chat_message'
import KVStore from '#models/kv_store'
import logger from '@adonisjs/core/services/logger'
import { DateTime } from 'luxon'
import { inject } from '@adonisjs/core'
@ -36,17 +37,23 @@ export class ChatService {
return [] // If no models are available, return empty suggestions
}
// Larger models generally give "better" responses, so pick the largest one
const largestModel = models.reduce((prev, current) => {
return prev.size > current.size ? prev : current
})
// Prefer the user's selected chat model. Fall back to the smallest
// installed model — picking the largest by file size 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.
// Suggestions are short prompts that don't benefit from a flagship model.
const lastModel = await KVStore.getValue('chat.lastModel')
const preferred = lastModel ? models.find((m) => m.name === lastModel) : undefined
const chosen =
preferred ??
models.reduce((prev, current) => (prev.size < current.size ? prev : current))
if (!largestModel) {
if (!chosen) {
return []
}
const response = await this.ollamaService.chat({
model: largestModel.name,
model: chosen.name,
messages: [
{
role: 'user',

View File

@ -5,6 +5,7 @@ import { DateTime } from 'luxon'
import { join } from 'path'
import CollectionManifest from '#models/collection_manifest'
import InstalledResource from '#models/installed_resource'
import WikipediaSelection from '#models/wikipedia_selection'
import { QueueService } from './queue_service.js'
import { RunDownloadJob } from '#jobs/run_download_job'
import { zimCategoriesSpecSchema, mapsSpecSchema, wikipediaSpecSchema } from '#validators/curated_collections'
@ -284,10 +285,17 @@ export class CollectionManifestService {
const seenZimIds = new Set<string>()
// Only skip the single Wikipedia file tracked by WikipediaSelection — not every file
// starting with `wikipedia_en_`. Curated category tiers (e.g. Medicine → Comprehensive)
// ship Wikipedia-themed ZIMs like `wikipedia_en_medicine_maxi` that must reconcile
// normally; otherwise their InstalledResource row gets wiped on every restart and the
// tier detection silently downgrades.
const wikipediaSelection = await WikipediaSelection.query().first()
const managedWikipediaFilename = wikipediaSelection?.filename ?? null
for (const file of zimFiles) {
console.log(`Processing ZIM file: ${file.name}`)
// Skip Wikipedia files (managed by WikipediaSelection model)
if (file.name.startsWith('wikipedia_en_')) continue
if (managedWikipediaFilename && file.name === managedWikipediaFilename) continue
const parsed = CollectionManifestService.parseZimFilename(file.name)
console.log(`Parsed ZIM filename:`, parsed)

View File

@ -1,16 +1,15 @@
import logger from '@adonisjs/core/services/logger'
import env from '#start/env'
import axios from 'axios'
import { DateTime } from 'luxon'
import InstalledResource from '#models/installed_resource'
import { RunDownloadJob } from '../jobs/run_download_job.js'
import { ZIM_STORAGE_PATH } from '../utils/fs.js'
import { join } from 'path'
import type {
ResourceUpdateCheckRequest,
ResourceUpdateInfo,
ContentUpdateCheckResult,
} from '../../types/collections.js'
import { NOMAD_API_DEFAULT_BASE_URL } from '../../constants/misc.js'
import { KiwixCatalogService, reconcileResourceUpdateState } from './kiwix_catalog_service.js'
const MAP_STORAGE_PATH = '/storage/maps'
@ -18,16 +17,13 @@ const ZIM_MIME_TYPES = ['application/x-zim', 'application/x-openzim', 'applicati
const PMTILES_MIME_TYPES = ['application/vnd.pmtiles', 'application/octet-stream']
export class CollectionUpdateService {
/**
* Check every installed resource against the upstream catalogs locally (Kiwix
* OPDS for ZIMs, GitHub for maps) no longer routed through the external
* project-nomad-api. Side-effect: persists each resource's available-update
* state (version + cool-off anchor) so the auto-updater can act on it later.
*/
async checkForUpdates(): Promise<ContentUpdateCheckResult> {
const nomadAPIURL = env.get('NOMAD_API_URL') || NOMAD_API_DEFAULT_BASE_URL
if (!nomadAPIURL) {
return {
updates: [],
checked_at: new Date().toISOString(),
error: 'Nomad API is not configured. Set the NOMAD_API_URL environment variable.',
}
}
const installed = await InstalledResource.all()
if (installed.length === 0) {
return {
@ -36,53 +32,53 @@ export class CollectionUpdateService {
}
}
const requestBody: ResourceUpdateCheckRequest = {
resources: installed.map((r) => ({
resource_id: r.resource_id,
resource_type: r.resource_type,
installed_version: r.version,
})),
}
try {
const response = await axios.post<ResourceUpdateInfo[]>(`${nomadAPIURL}/api/v1/resources/check-updates`, requestBody, {
timeout: 15000,
})
logger.info(
`[CollectionUpdateService] Update check complete: ${response.data.length} update(s) available`
const catalog = new KiwixCatalogService()
const latestByKey = await catalog.getLatestForResources(
installed.map((r) => ({ resource_id: r.resource_id, resource_type: r.resource_type }))
)
const updates = await this.enrichWithSizes(response.data)
const now = DateTime.now()
const updates: ResourceUpdateInfo[] = []
for (const resource of installed) {
const latest = latestByKey.get(`${resource.resource_type}:${resource.resource_id}`) ?? null
await reconcileResourceUpdateState(resource, latest, now)
if (latest && latest.version > resource.version) {
updates.push({
resource_id: resource.resource_id,
resource_type: resource.resource_type,
installed_version: resource.version,
latest_version: latest.version,
download_url: latest.download_url,
size_bytes: latest.size_bytes || undefined,
})
}
}
logger.info(
`[CollectionUpdateService] Local update check complete: ${updates.length} update(s) available`
)
const enriched = await this.enrichWithSizes(updates)
return {
updates,
updates: enriched,
checked_at: new Date().toISOString(),
}
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
logger.error(
`[CollectionUpdateService] Nomad API returned ${error.response.status}: ${JSON.stringify(error.response.data)}`
)
return {
updates: [],
checked_at: new Date().toISOString(),
error: 'Failed to check for content updates. The update service may be temporarily unavailable.',
}
}
const message =
error instanceof Error ? error.message : 'Unknown error contacting Nomad API'
const message = error instanceof Error ? error.message : 'Unknown error during update check'
logger.error(`[CollectionUpdateService] Failed to check for updates: ${message}`)
return {
updates: [],
checked_at: new Date().toISOString(),
error: 'Failed to contact the update service. Please try again later.',
error: 'Failed to check for content updates. Please try again later.',
}
}
}
async applyUpdate(
update: ResourceUpdateInfo
update: ResourceUpdateInfo,
options?: { auto?: boolean }
): Promise<{ success: boolean; jobId?: string; error?: string }> {
// Check if a download is already in progress for this URL
const existingJob = await RunDownloadJob.getByUrl(update.download_url)
@ -113,6 +109,7 @@ export class CollectionUpdateService {
resource_id: update.resource_id,
version: update.latest_version,
collection_ref: null,
auto: options?.auto ?? false,
},
})

View File

@ -139,11 +139,17 @@ export class ContainerRegistryService {
allTags.push(...data.tags)
}
// Handle pagination via Link header
// Handle pagination via Link header. Per the OCI/Docker registry spec the next-page
// URL is relative (e.g. "/v2/<repo>/tags/list?last=<tag>&n=1000"), so it must be
// resolved against the registry origin before re-fetching — assigning the raw relative
// path to `url` makes fetch() throw "Failed to parse URL". This silently broke update
// checks for any repo with >1000 tags (e.g. ollama/ollama, filebrowser/filebrowser),
// which is the root cause of #945. new URL(relative, base) also passes absolute
// next-URLs through unchanged, so it's safe for registries that return those.
const linkHeader = response.headers.get('link')
if (linkHeader) {
const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/)
url = match ? match[1] : ''
url = match ? new URL(match[1], `https://${parsed.registry}`).toString() : ''
} else {
url = ''
}
@ -200,6 +206,66 @@ export class ContainerRegistryService {
}
}
/**
* Estimate the compressed download size (in bytes) of an image tag for the
* given host architecture by summing its layer sizes from the manifest.
*
* Resolves a multi-arch manifest list/index down to the platform-specific
* manifest before summing `layers[].size`. Returns null on any failure so
* callers can fall back to a conservative fixed threshold rather than
* silently skipping a disk pre-flight check.
*/
async getImageDownloadSize(
parsed: ParsedImageReference,
tag: string,
hostArch: string
): Promise<number | null> {
try {
const token = await this.getToken(parsed.registry, parsed.fullName)
const manifestAccept = [
'application/vnd.oci.image.index.v1+json',
'application/vnd.docker.distribution.manifest.list.v2+json',
'application/vnd.oci.image.manifest.v1+json',
'application/vnd.docker.distribution.manifest.v2+json',
].join(', ')
const fetchManifest = async (ref: string) =>
this.fetchWithRetry(`https://${parsed.registry}/v2/${parsed.fullName}/manifests/${ref}`, {
headers: { Authorization: `Bearer ${token}`, Accept: manifestAccept },
})
const topRes = await fetchManifest(tag)
if (!topRes.ok) return null
let manifest = (await topRes.json()) as {
mediaType?: string
layers?: Array<{ size?: number }>
manifests?: Array<{ digest?: string; platform?: { architecture?: string } }>
}
// Multi-arch manifest list/index — resolve to the host-arch child manifest.
if (manifest.manifests?.length) {
const match =
manifest.manifests.find((m) => m.platform?.architecture === hostArch) ||
manifest.manifests[0]
if (!match?.digest) return null
const childRes = await fetchManifest(match.digest)
if (!childRes.ok) return null
manifest = (await childRes.json()) as { layers?: Array<{ size?: number }> }
}
if (!manifest.layers?.length) return null
return manifest.layers.reduce((total, layer) => total + (layer.size || 0), 0)
} catch (error) {
logger.warn(
`[ContainerRegistryService] Failed to get image size for ${parsed.fullName}:${tag}: ${error.message}`
)
return null
}
}
/**
* Extract the source repository URL from an image's OCI labels.
* Uses the standardized `org.opencontainers.image.source` label.

View File

@ -0,0 +1,550 @@
import logger from '@adonisjs/core/services/logger'
import { DateTime } from 'luxon'
import KVStore from '#models/kv_store'
import InstalledResource from '#models/installed_resource'
import { DownloadService } from '#services/download_service'
import { CollectionUpdateService } from '#services/collection_update_service'
import {
KiwixCatalogService,
reconcileResourceUpdateState,
type CatalogResult,
} from '#services/kiwix_catalog_service'
import { isWithinWindow, parseWindowMinutes } from '../utils/update_window.js'
import { recordResourceUpdateFailure } from '../utils/content_auto_update_backoff.js'
import type { Blocker, PreflightResult } from '../utils/image_disk_preflight.js'
/**
* Content auto-update is opt-in via a single global master switch and runs on
* its OWN window + per-window data cap (deliberately separate from the core/app
* `autoUpdate.*` window, since ZIM downloads are multi-GB and bandwidth
* sensitive). Defaults err toward an overnight window with no cap; the UI
* strongly recommends setting a cap.
*/
const DEFAULT_WINDOW_START = '02:00'
const DEFAULT_WINDOW_END = '05:00'
const DEFAULT_COOLOFF_HOURS = 72
/** Whole-feature failures (e.g. catalog unreachable) before it self-disables. */
const MAX_FEATURE_FAILURES = 3
export interface ContentAutoUpdateConfig {
/** Global master switch (`contentAutoUpdate.enabled`). */
enabled: boolean
windowStart: string
windowEnd: string
cooloffHours: number
/** Max NEW bytes initiated per window instance. 0 = unlimited. */
maxBytesPerWindow: number
}
/** Per-resource eligibility verdict (drives both selection and the status UI). */
export interface ContentEligibility {
eligible: boolean
reason: string
cooloffRemainingHours: number | null
}
/** An eligible resource paired with the catalog facts needed to download it. */
export interface ContentCandidate {
resource: InstalledResource
version: string
download_url: string
size_bytes: number
installed_at: DateTime
}
/** Outcome of the cap-bounded greedy selection. */
export interface ContentSelection {
selected: ContentCandidate[]
/** Single files larger than the whole cap — never auto-started (manual only). */
skippedOversize: ContentCandidate[]
/** Fit the cap but not this window's remaining budget — retried next window. */
deferred: ContentCandidate[]
}
export interface ContentAutoUpdateResourceStatus {
resource_id: string
resource_type: 'zim' | 'map'
current_version: string
available_update_version: string | null
size_bytes: number | null
eligible: boolean
reason: string
cooloff_remaining_hours: number | null
exceeds_cap: boolean
consecutive_failures: number
auto_disabled_reason: string | null
}
export interface ContentAutoUpdateStatus extends ContentAutoUpdateConfig {
withinWindow: boolean
windowBytesUsed: number
lastAttemptAt: string | null
lastResult: string | null
lastError: string | null
autoDisabledReason: string | null
resources: ContentAutoUpdateResourceStatus[]
}
/**
* Decision + safety layer for automatic content (ZIM/map) updates. This is the
* content-side counterpart to {@link AppAutoUpdateService}: it decides *whether*
* each installed resource with an available update should be downloaded now
* (master switch on + in the content window + past cool-off + within the data
* cap) and then drives the existing manual download path
* ({@link CollectionUpdateService.applyUpdate} {@link RunDownloadJob}).
*
* It never installs synchronously it dispatches resumable download jobs and
* lets the existing job-completion path advance the installed version and
* rebuild the Kiwix library.
*/
export class ContentAutoUpdateService {
constructor(
private downloadService: DownloadService,
private catalog: KiwixCatalogService = new KiwixCatalogService(),
private collectionUpdateService: CollectionUpdateService = new CollectionUpdateService()
) {}
/** Read the master switch plus the content-specific window/cool-off/cap. */
async getConfig(): Promise<ContentAutoUpdateConfig> {
const [enabled, windowStart, windowEnd, cooloffHours, maxBytes] = await Promise.all([
KVStore.getValue('contentAutoUpdate.enabled'),
KVStore.getValue('contentAutoUpdate.windowStart'),
KVStore.getValue('contentAutoUpdate.windowEnd'),
KVStore.getValue('contentAutoUpdate.cooloffHours'),
KVStore.getValue('contentAutoUpdate.maxBytesPerWindow'),
])
const parsedCooloff = Number(cooloffHours)
const parsedCap = Number(maxBytes)
return {
enabled: enabled ?? false,
windowStart: windowStart || DEFAULT_WINDOW_START,
windowEnd: windowEnd || DEFAULT_WINDOW_END,
// `Number(null) === 0`, so an unset value must fall through to the default
// rather than silently resolving to a zero cool-off. An explicit 0 is honored.
cooloffHours:
cooloffHours !== null && Number.isFinite(parsedCooloff) && parsedCooloff >= 0
? parsedCooloff
: DEFAULT_COOLOFF_HOURS,
maxBytesPerWindow:
maxBytes !== null && Number.isFinite(parsedCap) && parsedCap >= 0 ? parsedCap : 0,
}
}
/**
* Pure per-resource eligibility verdict. A resource is eligible when it has a
* detected newer version, is not self-disabled, and is past its cool-off
* (measured from first-detected). Version comparison is a lexicographic
* compare of the YYYY-MM stamps, which sorts chronologically.
*/
resourceEligibility(
resource: InstalledResource,
cooloffHours: number,
now: DateTime
): ContentEligibility {
if (!resource.available_update_version) {
return { eligible: false, reason: 'Up to date', cooloffRemainingHours: null }
}
if (resource.auto_update_disabled_reason) {
return {
eligible: false,
reason: 'Auto-update disabled after repeated failures',
cooloffRemainingHours: null,
}
}
if (!(resource.available_update_version > resource.version)) {
return { eligible: false, reason: 'Up to date', cooloffRemainingHours: null }
}
if (!resource.available_update_first_seen_at) {
return { eligible: false, reason: 'Cool-off pending', cooloffRemainingHours: cooloffHours }
}
const ageHours = now.diff(resource.available_update_first_seen_at, 'hours').hours
const remaining = cooloffHours - ageHours
if (remaining > 0) {
const rounded = Math.ceil(remaining)
return {
eligible: false,
reason: `In cool-off (${rounded}h remaining)`,
cooloffRemainingHours: rounded,
}
}
return {
eligible: true,
reason: `Eligible → ${resource.available_update_version}`,
cooloffRemainingHours: 0,
}
}
/**
* Pure cap-bounded greedy selection. Oldest-installed first (stale content is
* prioritized), tie-broken smallest-first for predictability.
*
* - size unknown (0) deferred (can't budget safely)
* - size > the WHOLE cap skippedOversize (never auto-started; manual only)
* - size remaining budget selected
* - otherwise deferred (fits the cap, not this window)
*/
selectUnderCap(
candidates: ContentCandidate[],
capBytes: number,
usedBytes: number
): ContentSelection {
const cap = capBytes > 0 ? capBytes : Number.POSITIVE_INFINITY
let remaining = Math.max(0, cap - usedBytes)
const selected: ContentCandidate[] = []
const skippedOversize: ContentCandidate[] = []
const deferred: ContentCandidate[] = []
const ordered = [...candidates].sort((a, b) => {
const at = a.installed_at?.toMillis?.() ?? 0
const bt = b.installed_at?.toMillis?.() ?? 0
if (at !== bt) return at - bt
return a.size_bytes - b.size_bytes
})
for (const candidate of ordered) {
if (candidate.size_bytes <= 0) {
deferred.push(candidate)
} else if (candidate.size_bytes > cap) {
skippedOversize.push(candidate)
} else if (candidate.size_bytes <= remaining) {
selected.push(candidate)
remaining -= candidate.size_bytes
} else {
deferred.push(candidate)
}
}
return { selected, skippedOversize, deferred }
}
/**
* Run-wide pre-flight: never auto-update content while ANY download is already
* running. Because content downloads are multi-GB and resumable, an in-flight
* download from a prior window naturally blocks new starts here exactly the
* "let in-flight finish, don't start new" behavior we want. Transient `skip`.
*/
async runGlobalPreflight(): Promise<PreflightResult> {
const blockers: Blocker[] = []
try {
const downloads = await this.downloadService.listDownloadJobs()
const active = downloads.filter(
(d) => !!d.status && ['waiting', 'active', 'delayed'].includes(d.status)
)
if (active.length > 0) {
blockers.push({ reason: `${active.length} download(s) in progress`, severity: 'skip' })
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.warn(`[ContentAutoUpdateService] Could not check active downloads: ${message}`)
}
return { ok: blockers.length === 0, blockers }
}
/**
* Entry point invoked by ContentAutoUpdateJob. Gates on the master switch +
* window, runs the local catalog check, then downloads as many eligible
* resources as fit under the per-window data cap.
*/
async attempt(): Promise<{ started: number; reason: string }> {
const config = await this.getConfig()
const now = DateTime.now()
if (!config.enabled) {
return { started: 0, reason: 'Content auto-update is disabled' }
}
if (!isWithinWindow(config.windowStart, config.windowEnd, now)) {
const reason = `Outside update window (${config.windowStart}-${config.windowEnd})`
await this.recordRun(reason)
return { started: 0, reason }
}
try {
// Reset the per-window budget once per window instance (the cron fires
// hourly but a window can span several hours).
await this.maybeResetWindowBudget(config, now)
// Local catalog check + persist available-update state for every resource.
const installed = await InstalledResource.all()
const latestByKey = await this.catalog.getLatestForResources(
installed.map((r) => ({ resource_id: r.resource_id, resource_type: r.resource_type }))
)
for (const resource of installed) {
const latest = latestByKey.get(`${resource.resource_type}:${resource.resource_id}`) ?? null
await reconcileResourceUpdateState(resource, latest, now)
}
const eligible = installed.filter(
(r) => this.resourceEligibility(r, config.cooloffHours, now).eligible
)
if (eligible.length === 0) {
await this.recordFeatureSuccess()
const reason = 'No eligible content updates'
await this.recordRun(reason)
return { started: 0, reason }
}
const global = await this.runGlobalPreflight()
if (!global.ok) {
await this.recordFeatureSuccess()
const reason = `Pre-flight blocked: ${global.blockers.map((b) => b.reason).join('; ')}`
await this.recordRun(reason)
return { started: 0, reason }
}
const candidates: ContentCandidate[] = eligible.map((r) => {
const latest = latestByKey.get(`${r.resource_type}:${r.resource_id}`) as CatalogResult
return {
resource: r,
version: latest.version,
download_url: latest.download_url,
size_bytes: r.available_update_size_bytes ?? latest.size_bytes ?? 0,
installed_at: r.installed_at,
}
})
const usedBytes = await this.getWindowBytesUsed()
const { selected, skippedOversize, deferred } = this.selectUnderCap(
candidates,
config.maxBytesPerWindow,
usedBytes
)
let started = 0
let failed = 0
let initiatedBytes = 0
for (const candidate of selected) {
const result = await this.collectionUpdateService.applyUpdate(
{
resource_id: candidate.resource.resource_id,
resource_type: candidate.resource.resource_type,
installed_version: candidate.resource.version,
latest_version: candidate.version,
download_url: candidate.download_url,
size_bytes: candidate.size_bytes || undefined,
},
{ auto: true }
)
if (result.success) {
// Success is NOT recorded here: applyUpdate only enqueues a resumable
// download. The per-resource backoff is cleared once the download
// actually completes (RunDownloadJob.onComplete) and incremented when it
// fails terminally (the worker `failed` handler). Recording success on
// dispatch would reset the counter every window and defeat self-disable.
initiatedBytes += candidate.size_bytes
started++
logger.info(
`[ContentAutoUpdateService] Started ${candidate.resource.resource_id}${candidate.version}`
)
} else {
// A failure to even enqueue is a genuine auto-update failure; no job runs,
// so no terminal `failed` event will follow — count it here.
await recordResourceUpdateFailure(candidate.resource, result.error ?? 'dispatch failed')
failed++
}
}
if (initiatedBytes > 0) {
await this.addWindowBytesUsed(initiatedBytes)
}
const parts = [`${started} started`]
if (failed) parts.push(`${failed} failed`)
if (skippedOversize.length) parts.push(`${skippedOversize.length} skipped (exceeds cap)`)
if (deferred.length) parts.push(`${deferred.length} deferred (over budget)`)
const reason = parts.join(', ')
await this.recordFeatureSuccess()
await this.recordRun(reason)
logger.info(`[ContentAutoUpdateService] Run complete: ${reason}`)
return { started, reason }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
await this.recordFeatureFailure(message)
await this.recordRun(`Failed: ${message}`)
logger.error(`[ContentAutoUpdateService] Run failed: ${message}`)
return { started: 0, reason: `Failed: ${message}` }
}
}
/**
* Evaluate what the next run *would* do, without hitting the network,
* persisting state, or dispatching anything. Operates on the available-update
* state already persisted by the last check (manual or auto), so run a "Check
* for Content Updates" first if you want fresh catalog data. Used by the
* `content-auto-update:dry-run` command.
*/
async dryRun(
overrides: {
now?: DateTime
forceEnabled?: boolean
cooloffHours?: number
windowStart?: string
windowEnd?: string
maxBytesPerWindow?: number
windowBytesUsed?: number
} = {}
): Promise<{
enabled: boolean
withinWindow: boolean
config: ContentAutoUpdateConfig
eligibleCount: number
selection: ContentSelection
}> {
const base = await this.getConfig()
const config: ContentAutoUpdateConfig = {
enabled: overrides.forceEnabled ? true : base.enabled,
windowStart: overrides.windowStart ?? base.windowStart,
windowEnd: overrides.windowEnd ?? base.windowEnd,
cooloffHours: overrides.cooloffHours ?? base.cooloffHours,
maxBytesPerWindow: overrides.maxBytesPerWindow ?? base.maxBytesPerWindow,
}
const now = overrides.now ?? DateTime.now()
const withinWindow = isWithinWindow(config.windowStart, config.windowEnd, now)
const pending = await InstalledResource.query().whereNotNull('available_update_version')
const eligible = pending.filter(
(r) => this.resourceEligibility(r, config.cooloffHours, now).eligible
)
const candidates: ContentCandidate[] = eligible.map((r) => ({
resource: r,
version: r.available_update_version!,
download_url: '(dry-run)',
size_bytes: r.available_update_size_bytes ?? 0,
installed_at: r.installed_at,
}))
const usedBytes = overrides.windowBytesUsed ?? (await this.getWindowBytesUsed())
const selection = this.selectUnderCap(candidates, config.maxBytesPerWindow, usedBytes)
return {
enabled: config.enabled,
withinWindow,
config,
eligibleCount: eligible.length,
selection,
}
}
// ── Per-window budget ─────────────────────────────────────────────────────────
/** Most-recent window-open boundary as an absolute timestamp (handles wrap). */
windowStartBoundary(windowStart: string, now: DateTime): DateTime {
const minutes = parseWindowMinutes(windowStart) ?? 0
const todayStart = now.startOf('day').plus({ minutes })
return now >= todayStart ? todayStart : todayStart.minus({ days: 1 })
}
/** Reset the window budget exactly once per entry into the window. */
private async maybeResetWindowBudget(
config: ContentAutoUpdateConfig,
now: DateTime
): Promise<void> {
const boundary = this.windowStartBoundary(config.windowStart, now)
const resetAtRaw = await KVStore.getValue('contentAutoUpdate.windowResetAt')
const resetAt = resetAtRaw ? DateTime.fromISO(resetAtRaw) : null
if (!resetAt || !resetAt.isValid || resetAt < boundary) {
await KVStore.setValue('contentAutoUpdate.windowBytesUsed', '0')
await KVStore.setValue('contentAutoUpdate.windowResetAt', now.toISO()!)
}
}
private async getWindowBytesUsed(): Promise<number> {
const raw = await KVStore.getValue('contentAutoUpdate.windowBytesUsed')
const num = Number(raw)
return Number.isFinite(num) && num > 0 ? num : 0
}
private async addWindowBytesUsed(bytes: number): Promise<void> {
const used = await this.getWindowBytesUsed()
await KVStore.setValue('contentAutoUpdate.windowBytesUsed', String(used + bytes))
}
// ── Backoff + run recording ───────────────────────────────────────────────────
// Per-resource backoff lives in ../utils/content_auto_update_backoff.ts so the
// job-completion path and the worker `failed` handler can share it without an
// import cycle. The feature-level backoff below stays here.
/** Clear the feature-level backoff after a clean run. */
private async recordFeatureSuccess(): Promise<void> {
await KVStore.setValue('contentAutoUpdate.consecutiveFailures', '0')
await KVStore.clearValue('contentAutoUpdate.lastError')
}
/** Record a whole-feature failure and self-disable the feature at the threshold. */
private async recordFeatureFailure(reason: string): Promise<void> {
const raw = await KVStore.getValue('contentAutoUpdate.consecutiveFailures')
const failures = (Number(raw) || 0) + 1
await KVStore.setValue('contentAutoUpdate.consecutiveFailures', String(failures))
await KVStore.setValue('contentAutoUpdate.lastError', reason)
if (failures >= MAX_FEATURE_FAILURES) {
await KVStore.setValue('contentAutoUpdate.enabled', false)
await KVStore.setValue(
'contentAutoUpdate.autoDisabledReason',
`Content auto-update disabled after ${failures} consecutive failures. Last error: ${reason}`
)
logger.error(
`[ContentAutoUpdateService] Feature auto-disabled after ${failures} consecutive failures`
)
}
}
private async recordRun(reason: string): Promise<void> {
await KVStore.setValue('contentAutoUpdate.lastAttemptAt', DateTime.now().toISO()!)
await KVStore.setValue('contentAutoUpdate.lastResult', reason)
}
// ── Status snapshot ───────────────────────────────────────────────────────────
/** Full state snapshot for the settings UI (resources with pending updates). */
async getStatus(): Promise<ContentAutoUpdateStatus> {
const config = await this.getConfig()
const now = DateTime.now()
const pending = await InstalledResource.query().whereNotNull('available_update_version')
const resources: ContentAutoUpdateResourceStatus[] = pending.map((resource) => {
const verdict = this.resourceEligibility(resource, config.cooloffHours, now)
const size = resource.available_update_size_bytes ?? null
const exceedsCap =
config.maxBytesPerWindow > 0 && size !== null && size > config.maxBytesPerWindow
return {
resource_id: resource.resource_id,
resource_type: resource.resource_type,
current_version: resource.version,
available_update_version: resource.available_update_version,
size_bytes: size,
eligible: verdict.eligible && !exceedsCap,
reason: exceedsCap ? 'Exceeds data cap — update manually' : verdict.reason,
cooloff_remaining_hours: verdict.cooloffRemainingHours,
exceeds_cap: exceedsCap,
consecutive_failures: resource.auto_update_consecutive_failures || 0,
auto_disabled_reason: resource.auto_update_disabled_reason,
}
})
const [lastAttemptAt, lastResult, lastError, autoDisabledReason, windowBytesUsed] =
await Promise.all([
KVStore.getValue('contentAutoUpdate.lastAttemptAt'),
KVStore.getValue('contentAutoUpdate.lastResult'),
KVStore.getValue('contentAutoUpdate.lastError'),
KVStore.getValue('contentAutoUpdate.autoDisabledReason'),
this.getWindowBytesUsed(),
])
return {
...config,
withinWindow: isWithinWindow(config.windowStart, config.windowEnd, now),
windowBytesUsed,
lastAttemptAt: lastAttemptAt || null,
lastResult: lastResult || null,
lastError: lastError || null,
autoDisabledReason: autoDisabledReason || null,
resources,
}
}
}

View File

@ -0,0 +1,167 @@
import { dirname, normalize } from 'node:path'
import env from '#start/env'
/**
* Security guardrails for user-defined ("custom app") containers.
*
* project-nomad runs containers as host siblings via the mounted Docker socket (DooD), so a
* misconfigured bind mount or image is a real host-takeover vector. The posture here is
* "guardrails with warnings": hard-block the genuinely catastrophic, warn-but-allow the merely
* risky so a trusted admin keeps their power without an easy foot-gun.
*/
export interface GuardEvaluation {
/** Hard rejections — the install cannot proceed until these are fixed. */
blocked: string[]
/** Advisory warnings — overridable via the "install anyway" force flag. */
warnings: string[]
}
/** Absolute host directories that must never be bind-mounted into a custom container. */
const SYSTEM_BLOCK_PREFIXES = ['/etc', '/proc', '/sys', '/boot', '/dev', '/run', '/var/run']
/** Registries we ship curated apps from; anything else is allowed but warned on. */
const TRUSTED_REGISTRIES = ['docker.io', 'registry-1.docker.io', 'ghcr.io', 'lscr.io', 'quay.io']
/** Resolve the managed storage root (where bind mounts are expected to live). */
export function getStorageRoot(): string {
return normalize(env.get('NOMAD_STORAGE_PATH', '/opt/project-nomad/storage')).replace(/\/+$/, '')
}
/** Normalize an absolute path: collapse `..`/`.` segments and strip any trailing slash. */
function normalizeHostPath(p: string): string {
return normalize(p).replace(/\/+$/, '') || '/'
}
/** True when `child` equals `ancestor` or sits beneath it. */
function isWithin(child: string, ancestor: string): boolean {
return child === ancestor || child.startsWith(ancestor + '/')
}
/**
* Evaluate user-supplied bind mounts. Hard-blocks the Docker socket, core system directories,
* and any mount at or above project-nomad's own install tree (which would expose its code/data).
* Warns on any host path outside the managed storage root.
*/
export function evaluateBindMounts(
volumes: { host_path: string; container_path: string }[]
): GuardEvaluation {
const blocked: string[] = []
const warnings: string[] = []
const storageRoot = getStorageRoot()
// The install tree is the parent of the storage root (e.g. /opt/project-nomad). Mounting it —
// or any ancestor, up to and including `/` — would hand a container project-nomad's own files.
const installRoot = dirname(storageRoot)
for (const { host_path: hostPath, container_path: containerPath } of volumes) {
const host = normalizeHostPath(hostPath)
if (!hostPath.startsWith('/')) {
blocked.push(`Volume host path "${hostPath}" must be an absolute path.`)
continue
}
if (!containerPath.startsWith('/')) {
blocked.push(`Volume container path "${containerPath}" must be an absolute path.`)
continue
}
// A colon is Docker's bind delimiter (host:container:options). A path containing one would be
// re-split by Docker into a different mount than the one validated here — reject it outright so
// the checks below can't be bypassed by a parse-differential. (The validator blocks this too;
// this keeps the guard self-defending for any caller that skips validation.)
if (hostPath.includes(':') || containerPath.includes(':')) {
blocked.push(`Volume paths must not contain a colon (":"): "${hostPath}" → "${containerPath}".`)
continue
}
// The Docker socket is the most dangerous mount of all — full control of the host daemon.
if (host.endsWith('docker.sock') || /\/docker\.sock$/.test(host)) {
blocked.push(
`Mounting the Docker socket ("${hostPath}") is not allowed — it grants full host control.`
)
continue
}
// Core system directories.
if (host === '/' || SYSTEM_BLOCK_PREFIXES.some((p) => isWithin(host, p))) {
blocked.push(`Mounting system directory "${hostPath}" is not allowed.`)
continue
}
// At or above project-nomad's own install tree (covers `/`, `/opt`, `/opt/project-nomad`).
if (host === installRoot || isWithin(installRoot, host)) {
blocked.push(
`Mounting "${hostPath}" would expose project-nomad's own files and is not allowed.`
)
continue
}
// Anything outside the managed storage root is allowed but flagged.
if (!isWithin(host, storageRoot)) {
warnings.push(
`Volume "${hostPath}" is outside the managed storage root (${storageRoot}). Make sure you trust this image with access to that path.`
)
}
}
return { blocked, warnings }
}
/**
* Evaluate a Docker image reference. Hard-blocks malformed references; warns on moving tags
* (`:latest`/untagged) and images from registries outside the trusted set.
*/
export function evaluateImageReference(image: string): GuardEvaluation {
const blocked: string[] = []
const warnings: string[] = []
const ref = image.trim()
// Loose validity check: no whitespace/control chars, and a sane character set for an image ref.
if (!ref || /\s/.test(ref) || !/^[\w./:@-]+$/.test(ref)) {
blocked.push(`"${image}" is not a valid image reference.`)
return { blocked, warnings }
}
// Split off any digest, then any tag, to inspect the registry and tag.
const [nameAndTag] = ref.split('@')
const firstSegment = nameAndTag.split('/')[0]
const hasRegistryHost =
nameAndTag.includes('/') && (firstSegment.includes('.') || firstSegment.includes(':'))
const registry = hasRegistryHost ? firstSegment.split(':')[0] : 'docker.io'
if (!TRUSTED_REGISTRIES.includes(registry)) {
warnings.push(
`Image is from "${registry}", which is outside project-nomad's trusted registries. Only install images you trust.`
)
}
// Determine the tag (ignore a colon that's part of a registry host:port in the first segment).
const remainder = hasRegistryHost ? nameAndTag.slice(firstSegment.length + 1) : nameAndTag
const tag = remainder.includes(':') ? remainder.split(':').pop() : undefined
const hasDigest = ref.includes('@sha256:')
if (!hasDigest && (!tag || tag === 'latest')) {
warnings.push(
`Image "${image}" uses a moving tag (${tag ? ':latest' : 'no tag'}). Pin a specific version for reproducible installs.`
)
}
return { blocked, warnings }
}
/** Combine bind-mount and image evaluations into a single result. */
export function evaluateCustomApp(input: {
image?: string
volumes?: { host_path: string; container_path: string }[]
}): GuardEvaluation {
const bind = evaluateBindMounts(input.volumes ?? [])
const img = input.image ? evaluateImageReference(input.image) : { blocked: [], warnings: [] }
return {
blocked: [...bind.blocked, ...img.blocked],
warnings: [...bind.warnings, ...img.warnings],
}
}
/** Default resource caps applied to custom containers unless the user overrides them. */
export const DEFAULT_MEMORY_MB = 1024
export const DEFAULT_CPUS = 1

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -0,0 +1,338 @@
import axios from 'axios'
import { XMLParser } from 'fast-xml-parser'
import { DateTime } from 'luxon'
import logger from '@adonisjs/core/services/logger'
import InstalledResource from '#models/installed_resource'
import { isRawListRemoteZimFilesResponse } from '../../util/zim.js'
/**
* Local, in-process freshness check for installed content (Kiwix ZIM files +
* PMTiles maps). This replaces the former dependency on the external
* project-nomad-api `/api/v1/resources/check-updates` endpoint every NOMAD
* instance now queries the upstream catalogs directly.
*
* Downloads have always gone straight to the Kiwix/GitHub mirrors regardless of
* who performed the check, so moving the check in-process only shifts the
* lightweight *catalog* lookup. To stay mirror-respectful the auto-updater gates
* these calls behind the update window and bounds their concurrency; sizes come
* from the catalog metadata so we avoid per-file HEAD requests.
*
* Robustness over the old API: ZIM lookups use the OPDS exact `name=` filter
* (no lossy keyword stripping) and every returned link is still validated
* against the authoritative `^<id>_YYYY-MM\.zim$` filename regex, so a substring
* match in the catalog can never resolve to the wrong book. Parsing is fully
* defensive a malformed entry is skipped, never thrown.
*/
const KIWIX_CATALOG_URL = 'https://browse.library.kiwix.org/catalog/v2/entries'
const GITHUB_PMTILES_URL =
'https://api.github.com/repos/Crosstalk-Solutions/project-nomad-maps/contents/pmtiles'
const CATALOG_TIMEOUT_MS = 15000
/** Bounded paginated fallback scan when the exact `name=` lookup comes up empty. */
const KIWIX_PAGE_SIZE = 60
const MAX_KIWIX_FETCHES = 5
/** Concurrent ZIM catalog lookups — keep small to avoid hammering the mirror. */
const ZIM_CHECK_CONCURRENCY = 4
/** The newest available version of a single resource (a YYYY-MM date stamp). */
export interface CatalogResult {
version: string
download_url: string
size_bytes: number
}
interface CatalogZimEntry {
name: string | null
download_url: string
file_name: string
size_bytes: number
}
interface GithubContentEntry {
name: string
download_url: string | null
size: number
}
function escapeRegex(input: string): string {
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
export class KiwixCatalogService {
private parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '',
textNodeName: '#text',
})
/**
* Resolve the newest available version for a batch of installed resources.
* Returns a map keyed by `"<resource_type>:<resource_id>"`; resources with no
* available newer version (or a failed lookup) are simply absent.
*
* ZIMs are checked one OPDS request each (bounded concurrency). All maps are
* resolved from a single GitHub directory listing.
*/
async getLatestForResources(
resources: Array<{ resource_id: string; resource_type: 'zim' | 'map' }>
): Promise<Map<string, CatalogResult>> {
const result = new Map<string, CatalogResult>()
const zims = resources.filter((r) => r.resource_type === 'zim')
const maps = resources.filter((r) => r.resource_type === 'map')
await this.forEachWithConcurrency(zims, ZIM_CHECK_CONCURRENCY, async (r) => {
try {
const latest = await this.getLatestZim(r.resource_id)
if (latest) result.set(`zim:${r.resource_id}`, latest)
} catch (error) {
logger.warn(
`[KiwixCatalogService] ZIM check failed for ${r.resource_id}: ${error instanceof Error ? error.message : error}`
)
}
})
if (maps.length > 0) {
try {
const listing = await this.fetchMapListing()
for (const r of maps) {
const latest = this.pickNewestMap(listing, r.resource_id)
if (latest) result.set(`map:${r.resource_id}`, latest)
}
} catch (error) {
logger.warn(
`[KiwixCatalogService] Map listing fetch failed: ${error instanceof Error ? error.message : error}`
)
}
}
return result
}
/** Newest catalog version of a single ZIM book, or null if none/older. */
async getLatestZim(resourceId: string): Promise<CatalogResult | null> {
const pattern = new RegExp(`^${escapeRegex(resourceId)}_(\\d{4}-\\d{2})\\.zim$`)
// 1. Exact-name lookup (the robust path).
const named = await this.fetchZimEntries({ name: resourceId, count: 50, start: 0 })
const exact = this.pickNewestZim(named, pattern)
if (exact) return exact
// 2. Fallback: bounded keyword scan in case the catalog ignored `name=` or
// indexes the book under a slightly different name.
return this.scanZimByQuery(resourceId, pattern)
}
/** Newest catalog version of a single PMTiles map, or null if none/older. */
async getLatestMap(resourceId: string): Promise<CatalogResult | null> {
const listing = await this.fetchMapListing()
return this.pickNewestMap(listing, resourceId)
}
// ── ZIM internals ───────────────────────────────────────────────────────────
private pickNewestZim(entries: CatalogZimEntry[], pattern: RegExp): CatalogResult | null {
let latest: CatalogResult | null = null
for (const entry of entries) {
const match = entry.file_name.match(pattern)
if (!match) continue
const version = match[1]
if (!latest || version > latest.version) {
latest = { version, download_url: entry.download_url, size_bytes: entry.size_bytes }
}
}
return latest
}
private async scanZimByQuery(
resourceId: string,
pattern: RegExp
): Promise<CatalogResult | null> {
let start = 0
let total = 0
let latest: CatalogResult | null = null
for (let i = 0; i < MAX_KIWIX_FETCHES; i++) {
const { entries, totalResults } = await this.fetchZimEntriesPage({
q: resourceId,
count: KIWIX_PAGE_SIZE,
start,
})
total = totalResults
if (entries.length === 0) break
start += entries.length
const candidate = this.pickNewestZim(entries, pattern)
if (candidate && (!latest || candidate.version > latest.version)) {
latest = candidate
}
if (start >= total) break
}
return latest
}
private async fetchZimEntries(params: {
name?: string
q?: string
count: number
start: number
}): Promise<CatalogZimEntry[]> {
const { entries } = await this.fetchZimEntriesPage(params)
return entries
}
private async fetchZimEntriesPage(params: {
name?: string
q?: string
count: number
start: number
}): Promise<{ entries: CatalogZimEntry[]; totalResults: number }> {
const res = await axios.get(KIWIX_CATALOG_URL, {
params: {
start: params.start,
count: params.count,
lang: 'eng',
...(params.name ? { name: params.name } : {}),
...(params.q ? { q: params.q } : {}),
},
responseType: 'text',
timeout: CATALOG_TIMEOUT_MS,
})
return this.parseZimEntries(res.data)
}
private parseZimEntries(xml: string): { entries: CatalogZimEntry[]; totalResults: number } {
let parsed: any
try {
parsed = this.parser.parse(xml)
} catch {
return { entries: [], totalResults: 0 }
}
if (!isRawListRemoteZimFilesResponse(parsed)) {
return { entries: [], totalResults: 0 }
}
const feed = parsed.feed
const totalResults = Number(feed?.totalResults)
const rawEntries = feed?.entry
? Array.isArray(feed.entry)
? feed.entry
: [feed.entry]
: []
const entries: CatalogZimEntry[] = []
for (const raw of rawEntries) {
if (!raw || typeof raw !== 'object') continue
const links = Array.isArray(raw.link) ? raw.link : raw.link ? [raw.link] : []
const downloadLink = links.find(
(link: any) =>
link &&
typeof link === 'object' &&
link.type === 'application/x-zim' &&
typeof link.href === 'string'
)
if (!downloadLink) continue
// The OPDS href ends with `.meta4`; strip it to get the real .zim URL.
const href: string = downloadLink.href
const download_url = href.endsWith('.meta4') ? href.slice(0, -'.meta4'.length) : href
const file_name = download_url.split('/').pop() || ''
if (!file_name) continue
const size_bytes = Number.parseInt(downloadLink.length, 10) || 0
entries.push({
name: typeof raw.name === 'string' ? raw.name : null,
download_url,
file_name,
size_bytes,
})
}
return { entries, totalResults: Number.isFinite(totalResults) ? totalResults : 0 }
}
// ── Map internals ────────────────────────────────────────────────────────────
private async fetchMapListing(): Promise<GithubContentEntry[]> {
const res = await axios.get(GITHUB_PMTILES_URL, {
headers: { Accept: 'application/vnd.github+json' },
timeout: CATALOG_TIMEOUT_MS,
})
return Array.isArray(res.data) ? res.data : []
}
private pickNewestMap(listing: GithubContentEntry[], resourceId: string): CatalogResult | null {
const pattern = new RegExp(`^${escapeRegex(resourceId)}_(\\d{4}-\\d{2})\\.pmtiles$`)
let latest: CatalogResult | null = null
for (const file of listing) {
if (!file || typeof file.name !== 'string' || !file.download_url) continue
const match = file.name.match(pattern)
if (!match) continue
const version = match[1]
if (!latest || version > latest.version) {
latest = {
version,
download_url: file.download_url,
size_bytes: typeof file.size === 'number' ? file.size : 0,
}
}
}
return latest
}
// ── Shared ───────────────────────────────────────────────────────────────────
private async forEachWithConcurrency<T>(
items: T[],
concurrency: number,
worker: (item: T) => Promise<void>
): Promise<void> {
let cursor = 0
const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
while (cursor < items.length) {
const index = cursor++
await worker(items[index])
}
})
await Promise.all(runners)
}
}
/**
* Persist the latest-known available-update state onto an installed resource.
* Shared by the manual check and the auto-updater so both keep the cool-off
* anchor consistent.
*
* The first-seen anchor is reset **only** when the available version string
* actually changes, so a manual "Check for updates" never resets the auto
* cool-off clock. State is cleared entirely once the resource is current (the
* update got installed, or the upstream release was withdrawn).
*/
export async function reconcileResourceUpdateState(
resource: InstalledResource,
latest: CatalogResult | null,
now: DateTime
): Promise<void> {
const hasUpdate = latest !== null && latest.version > resource.version
if (hasUpdate) {
if (resource.available_update_version !== latest!.version) {
resource.available_update_version = latest!.version
resource.available_update_first_seen_at = now
}
// Keep the cached size fresh even when the version is unchanged (the catalog
// may report a size it lacked on a previous check).
const size = latest!.size_bytes || null
if (resource.available_update_size_bytes !== size) {
resource.available_update_size_bytes = size
}
} else if (resource.available_update_version !== null) {
resource.available_update_version = null
resource.available_update_size_bytes = null
resource.available_update_first_seen_at = null
}
if (resource.$isDirty) {
await resource.save()
}
}

View File

@ -187,7 +187,71 @@ export class KiwixLibraryService {
.filter((b) => b.id && b.path)
}
async rebuildFromDisk(opts?: { excludeFilenames?: string[] }): Promise<void> {
/**
* Returns the number of books currently listed in the library XML, or 0 if the
* file doesn't exist yet. Used to report a before/after delta on a manual rescan.
*/
async getBookCount(): Promise<number> {
try {
const content = await readFile(this.getLibraryFilePath(), 'utf-8')
return this._parseExistingBooks(content).length
} catch (err: any) {
if (err.code === 'ENOENT') return 0
throw err
}
}
/**
* True if the library XML parses and has a <library> root. A truncated or
* corrupt file (e.g. an interrupted write) fails this even though it exists,
* so the caller can rebuild rather than leave Kiwix serving a broken library.
* An empty-but-well-formed library is considered valid (nothing to repair).
*/
private _isValidLibraryXml(xmlContent: string): boolean {
try {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
isArray: (name) => name === 'book',
})
const parsed = parser.parse(xmlContent)
return parsed?.library !== undefined && parsed?.library !== null
} catch {
return false
}
}
/**
* Boot-time safety net: if the library XML is missing or unparseable, rebuild
* it from the ZIM files on disk so Kiwix (running in library mode with
* --monitorLibrary) doesn't come up serving an empty/broken library with no
* path to recovery. This covers files lost or corrupted outside the normal
* download flow (storage relocation, interrupted write, manual deletion).
*
* Returns true if a rebuild was performed. Filesystem errors other than
* "not found" are surfaced rather than masked by a rebuild.
*/
async ensureLibraryXmlHealthy(): Promise<boolean> {
let content: string
try {
content = await readFile(this.getLibraryFilePath(), 'utf-8')
} catch (err: any) {
if (err?.code === 'ENOENT') {
logger.warn('[KiwixLibraryService] Library XML missing on startup; rebuilding from disk.')
await this.rebuildFromDisk()
return true
}
throw err
}
if (this._isValidLibraryXml(content)) return false
logger.warn('[KiwixLibraryService] Library XML present but invalid; rebuilding from disk.')
await this.rebuildFromDisk()
return true
}
async rebuildFromDisk(opts?: { excludeFilenames?: string[] }): Promise<number> {
const dirPath = join(process.cwd(), ZIM_STORAGE_PATH)
await ensureDirectoryExists(dirPath)
@ -221,6 +285,7 @@ export class KiwixLibraryService {
const xml = this._buildXml(books)
await this._atomicWrite(xml)
logger.info(`[KiwixLibraryService] Rebuilt library XML with ${books.length} book(s).`)
return books.length
}
async addBook(filename: string): Promise<void> {

View File

@ -21,6 +21,7 @@ import logger from '@adonisjs/core/services/logger'
import { assertNotPrivateUrl } from '#validators/common'
import InstalledResource from '#models/installed_resource'
import { CollectionManifestService } from './collection_manifest_service.js'
import { decideSupersededDeletion } from '../utils/superseded_resource.js'
import type { CollectionWithStatus, MapsSpec } from '../../types/collections.js'
import type { Country, CountryCode, CountryGroup, MapExtractPreflight } from '../../types/maps.js'
import {
@ -199,10 +200,18 @@ export class MapService implements IMapService {
const parsed = CollectionManifestService.parseMapFilename(filename)
if (!parsed) continue
const filepath = join(process.cwd(), this.mapStoragePath, 'pmtiles', filename)
const pmtilesDir = join(process.cwd(), this.mapStoragePath, 'pmtiles')
const filepath = join(pmtilesDir, filename)
const stats = await getFileStatsIfExists(filepath)
try {
// Capture the prior install for this resource_id before updateOrCreate
// overwrites it, so we know the old file to clean up (#634).
const prior = await InstalledResource.query()
.where('resource_id', parsed.resource_id)
.where('resource_type', 'map')
.first()
const { DateTime } = await import('luxon')
await InstalledResource.updateOrCreate(
{ resource_id: parsed.resource_id, resource_type: 'map' },
@ -215,6 +224,31 @@ export class MapService implements IMapService {
}
)
logger.info(`[MapService] Created InstalledResource entry for: ${parsed.resource_id}`)
// Remove the superseded prior version's pmtiles file if every safety
// rail passes (see decideSupersededDeletion). Maps have no library index,
// so a direct delete of the recorded old file is sufficient.
const decision = decideSupersededDeletion({
existing: prior ? { file_path: prior.file_path, version: prior.version } : null,
newFilePath: filepath,
newVersion: parsed.version,
newFileExists: !!stats,
storageBaseDir: pmtilesDir,
})
if (decision.delete && decision.path) {
try {
await deleteFileIfExists(decision.path)
logger.info(
`[MapService] Removed superseded ${parsed.resource_id} file: ${decision.path}`
)
} catch (err) {
logger.warn(`[MapService] Failed to remove superseded file ${decision.path}:`, err)
}
} else if (decision.reason !== 'first_install' && decision.reason !== 'same_file') {
logger.info(
`[MapService] Kept prior ${parsed.resource_id} file (reason: ${decision.reason})`
)
}
} catch (error) {
logger.error(`[MapService] Failed to create InstalledResource for ${filename}:`, error)
}
@ -301,7 +335,7 @@ export class MapService implements IMapService {
}
const contentLength = response.headers['content-length']
const size = contentLength ? parseInt(contentLength, 10) : 0
const size = contentLength ? parseInt(contentLength.toString(), 10) : 0
return { filename, size }
} catch (error: any) {
@ -457,6 +491,18 @@ export class MapService implements IMapService {
return await listDirectoryContentsRecursive(this.baseDirPath)
}
/**
* Compare two map-file versions (YYYY-MM, or null for an undated legacy file). Returns >0 if
* `a` is newer than `b`, <0 if older, 0 if equal. A dated build is always newer than an undated
* legacy file; two dated builds compare lexicographically (correct for zero-padded YYYY-MM).
*/
private static compareMapVersions(a: string | null, b: string | null): number {
if (a === b) return 0
if (a === null) return -1
if (b === null) return 1
return a < b ? -1 : 1
}
private generateSourcesArray(host: string | null, regions: FileEntry[], protocol: string = 'http'): BaseStylesFile['sources'][] {
const sources: BaseStylesFile['sources'][] = []
const baseUrl = this.getPublicFileBaseUrl(host, 'pmtiles', protocol)
@ -474,23 +520,47 @@ export class MapService implements IMapService {
sources.push(worldSource)
}
// Dedupe by region name, keeping only the newest file per region. The source name is the
// date-stripped region (e.g. both "washington.pmtiles" and "washington_2025-12.pmtiles" map
// to "washington"). Emitting both produces duplicate source keys 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 guard against it here so the style
// stays valid even if cleanup hasn't run. A dated build beats an undated legacy file; between
// two dated builds the later YYYY-MM wins (lexicographic compare is correct for that format).
const bestByRegion = new Map<string, { region: FileEntry; version: string | null }>()
for (const region of regions) {
if (region.type === 'file' && region.name.endsWith('.pmtiles')) {
// Strip .pmtiles and date suffix (e.g. "alaska_2025-12" -> "alaska") for stable source names
const parsed = CollectionManifestService.parseMapFilename(region.name)
const regionName = parsed ? parsed.resource_id : region.name.replace('.pmtiles', '')
const source: BaseStylesFile['sources'] = {}
const sourceUrl = urlJoin(baseUrl, region.name)
source[regionName] = {
type: 'vector',
attribution: PMTILES_ATTRIBUTION,
url: `pmtiles://${sourceUrl}`,
const version = parsed?.version ?? null
const existing = bestByRegion.get(regionName)
if (!existing || MapService.compareMapVersions(version, existing.version) > 0) {
if (existing) {
logger.warn(
`[MapService] Duplicate map region "${regionName}": using "${region.name}" over "${existing.region.name}" (keeping newest)`
)
}
bestByRegion.set(regionName, { region, version })
} else {
logger.warn(
`[MapService] Duplicate map region "${regionName}": skipping "${region.name}" in favor of "${existing.region.name}" (keeping newest)`
)
}
sources.push(source)
}
}
for (const [regionName, { region }] of bestByRegion) {
const source: BaseStylesFile['sources'] = {}
const sourceUrl = urlJoin(baseUrl, region.name)
source[regionName] = {
type: 'vector',
attribution: PMTILES_ATTRIBUTION,
url: `pmtiles://${sourceUrl}`,
}
sources.push(source)
}
return sources
}

View File

@ -481,9 +481,43 @@ export class OllamaService {
*/
public static readonly EMBED_MAX_INPUT_CHARS = 4000
/**
* Aggressive 2048-safe character cap, applied only on a context-length retry. nomic-embed-text:v1.5
* defaults to a 2048-token context, and on the OpenAI-compat fallback path (or an older Ollama that
* ignores num_ctx for embeddings) we cannot widen it at request time. 2000 chars stays under 2048
* tokens even for the densest content (~1 char/token code/markup), so an oversized chunk gets
* truncated-and-kept instead of silently dropped from Qdrant and the embed job stops re-embedding
* the whole file 30x on the one bad chunk (#881).
*/
public static readonly EMBED_CONTEXT_SAFE_CHARS = 2000
/**
* True if the error is the model rejecting input that exceeds its context window
* ("input length exceeds the context length"). Matches both the native /api/embed axios error
* shape and the OpenAI-compat BadRequestError. Drives the truncate-and-retry here and the
* non-retryable classification in EmbedFileJob (#881).
*/
public static isContextLengthError(err: unknown): boolean {
const parts: string[] = []
if (err instanceof Error && err.message) parts.push(err.message)
const anyErr = err as any
const data = anyErr?.response?.data
if (data) parts.push(typeof data === 'string' ? data : JSON.stringify(data))
if (anyErr?.error) parts.push(typeof anyErr.error === 'string' ? anyErr.error : JSON.stringify(anyErr.error))
const haystack = parts.join(' ').toLowerCase()
return (
(haystack.includes('context length') && haystack.includes('exceed')) ||
haystack.includes('input length exceeds')
)
}
/**
* Generate embeddings for the given input strings.
* Tries the Ollama native /api/embed endpoint first, falls back to /v1/embeddings.
*
* If the first attempt fails because a chunk exceeds the model's context window, retries once
* with an aggressive 2048-safe truncation (EMBED_CONTEXT_SAFE_CHARS) so the chunk is embedded
* (start-of-chunk) rather than silently dropped from Qdrant (#881).
*/
public async embed(model: string, input: string[]): Promise<{ embeddings: number[][] }> {
await this._ensureDependencies()
@ -491,41 +525,49 @@ export class OllamaService {
throw new Error('AI service is not initialized.')
}
// Runtime safety net (#881). The OpenAI-compat fallback has no equivalent of
// truncate:true, so a chunk that exceeds the model's loaded context_length
// (often 2048 for nomic-embed-text:v1.5) returns 400 and the chunk is silently
// dropped from Qdrant. Pre-capping at the input layer protects both paths.
const safeInput = input.map((s) =>
s.length > OllamaService.EMBED_MAX_INPUT_CHARS
? s.slice(0, OllamaService.EMBED_MAX_INPUT_CHARS)
: s
)
const truncatedCount = input.reduce(
(n, s) => (s.length > OllamaService.EMBED_MAX_INPUT_CHARS ? n + 1 : n),
0
)
if (truncatedCount > 0) {
logger.debug(
'[OllamaService] embed: pre-capped %d/%d inputs at %d chars',
truncatedCount,
input.length,
OllamaService.EMBED_MAX_INPUT_CHARS
)
}
const cap = (arr: string[], max: number) => arr.map((s) => (s.length > max ? s.slice(0, max) : s))
// Generous pre-cap (#881): fine for the native path (num_ctx=8192) but can still exceed a
// 2048-context fallback on dense content. The context-length retry below is the hard backstop.
const safeInput = cap(input, OllamaService.EMBED_MAX_INPUT_CHARS)
try {
// Prefer Ollama native endpoint (supports batch input natively).
// Pass num_ctx explicitly so we don't depend on the embedding model's
// modelfile defaults. Some installs ship nomic-embed-text:v1.5 with
// num_ctx=2048, which our chunker (sized for ~1500 tokens) can exceed
// on dense content, causing "input length exceeds context length" errors.
// truncate:true is a runtime safety net for any chunk that still overshoots.
// 8192 matches nomic-embed-text:v1.5's RoPE-extrapolated max.
return await this._embedWithFallback(model, safeInput)
} catch (err) {
if (!OllamaService.isContextLengthError(err)) throw err
// One or more chunks exceeded the model's context even after the pre-cap — typically an
// older Ollama that ignores num_ctx for embeddings, or the OpenAI-compat fallback path.
// Retry once, truncated hard enough to fit a 2048-token context at any density, so the
// chunk is embedded (truncated) instead of dropped and the job doesn't storm.
const hardCapped = cap(input, OllamaService.EMBED_CONTEXT_SAFE_CHARS)
const reduced = hardCapped.reduce((n, s, i) => (s.length < safeInput[i].length ? n + 1 : n), 0)
logger.warn(
'[OllamaService] embed: context-length overflow; retrying %d/%d inputs hard-capped at %d chars',
reduced,
input.length,
OllamaService.EMBED_CONTEXT_SAFE_CHARS
)
return await this._embedWithFallback(model, hardCapped)
}
}
/**
* Single embed attempt: native /api/embed first, then the OpenAI-compat /v1/embeddings fallback.
* Both paths request num_ctx/truncate (Ollama's OpenAI-compat shim forwards them). A context-length
* error from the native path is re-thrown rather than falling back, because the fallback has a
* smaller effective context and would only fail the same way the caller (embed) retries it
* truncated instead.
*/
private async _embedWithFallback(model: string, input: string[]): Promise<{ embeddings: number[][] }> {
try {
// Pass num_ctx explicitly so we don't depend on the embedding model's modelfile defaults.
// Some installs ship nomic-embed-text:v1.5 with num_ctx=2048; 8192 matches its RoPE-extrapolated
// max. truncate:true is a server-side net for any chunk that still overshoots.
const response = await axios.post(
`${this.baseUrl}/api/embed`,
{
model,
input: safeInput,
input,
truncate: true,
options: { num_ctx: 8192 },
},
@ -538,22 +580,26 @@ export class OllamaService {
}
return { embeddings: response.data.embeddings }
} catch (err) {
// Capture the original error so we know *why* we fell back. Earlier bare
// catches here masked recurring "input length exceeds context length"
// failures for months (#369, #670, #881) — without this log we have no
// signal that /api/embed is the broken path vs the fallback.
// Let context-length errors bubble so embed() can retry with a smaller cap; the fallback
// endpoint (smaller effective context, no num_ctx honored on older Ollama) can't help here.
if (OllamaService.isContextLengthError(err)) throw err
// Log the original error so we know *why* we fell back. Earlier bare catches here masked
// recurring failures for months (#369, #670, #881).
logger.warn(
'[OllamaService] /api/embed failed, falling back to /v1/embeddings: %s',
err instanceof Error ? err.message : String(err)
)
// Fall back to OpenAI-compatible /v1/embeddings.
// Explicitly request float format — some backends (e.g. LM Studio) don't reliably
// implement the base64 encoding the OpenAI SDK requests by default.
const results = await this.openai.embeddings.create({
// Fall back to OpenAI-compatible /v1/embeddings. Explicitly request float format — some
// backends (e.g. LM Studio) don't reliably implement the base64 the OpenAI SDK defaults to.
// truncate/num_ctx are forwarded by Ollama's OpenAI-compat shim; the SDK types omit them,
// hence the cast. We only ever talk to a local Ollama here, not real OpenAI.
const results = await this.openai!.embeddings.create({
model,
input: safeInput,
input,
encoding_format: 'float',
})
truncate: true,
options: { num_ctx: 8192 },
} as any)
return { embeddings: results.data.map((e) => e.embedding as number[]) }
}
}

View File

@ -1,11 +1,11 @@
import { Queue } from 'bullmq'
import queueConfig from '#config/queue'
// 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.
// 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).
export class QueueService {
private queues: Map<string, Queue> = new Map()

View File

@ -18,6 +18,7 @@ import { join, resolve, sep } from 'node:path'
import KVStore from '#models/kv_store'
import KbIngestState from '#models/kb_ingest_state'
import { decideScanAction, type IngestPolicy } from '../utils/kb_ingest_decision.js'
import { decideContentReindex, type ReindexOutcome } from '../utils/content_reindex_decision.js'
import KbRatioRegistry from '#models/kb_ratio_registry'
import { decideWarnings } from '../utils/kb_warning_decision.js'
import type { FileWarning, FileWarningsResult, StoredFileInfo } from '../../types/rag.js'
@ -46,6 +47,10 @@ export class RagService {
public static UPLOADS_STORAGE_PATH = 'storage/kb_uploads'
public static CONTENT_COLLECTION_NAME = 'nomad_knowledge_base'
public static EMBEDDING_DIMENSION = 768 // Nomic Embed Text v1.5 dimension is 768
// Upper bound on distinct sources returned by Qdrant's facet API. Real
// NOMADs cap out at a few hundred ZIM files + uploaded PDFs; 10k leaves
// generous headroom without paying the cost of an unbounded request.
public static FACET_SOURCE_LIMIT = 10_000
public static MODEL_CONTEXT_LENGTH = 2048 // nomic-embed-text has 2K token context
public static MAX_SAFE_TOKENS = 1600 // Leave buffer for prefix and tokenization variance
public static TARGET_TOKENS_PER_CHUNK = 1500 // Target 1500 tokens per chunk for embedding
@ -1007,6 +1012,29 @@ export class RagService {
}
}
// Boost when query keywords match the chunk's section/article heading. ZIM
// content carries this structural metadata (already fetched, no extra cost),
// and a query term appearing in a heading is a strong relevance signal that
// body-text matching alone misses. Same conservative, score-scaled, diminishing
// -returns shape as the boosts above, so it can't promote a weak match.
const headingText = [result.full_title, result.section_title, result.article_title]
.filter(Boolean)
.join(' ')
.toLowerCase()
if (headingText) {
const headingHits = queryKeywords.filter((kw) =>
headingText.includes(kw.toLowerCase())
).length
if (headingHits > 0) {
const headingRatio = headingHits / Math.max(queryKeywords.length, 1)
const headingBoost = Math.sqrt(headingRatio) * 0.1 * result.score
logger.debug(
`[RAG] Heading match: ${headingHits}/${queryKeywords.length} - Boost: ${headingBoost.toFixed(3)}`
)
finalScore += headingBoost
}
}
finalScore = Math.min(1.0, finalScore + keywordBoost)
return {
@ -1069,29 +1097,21 @@ export class RagService {
RagService.EMBEDDING_DIMENSION
)
// Use Qdrant's facet API to enumerate distinct `source` values in one
// call. The previous scroll-loop walked every point in the collection
// (3M+ on a fully-ingested NOMAD) just to learn the ~40 unique sources,
// which made this endpoint take 50+ seconds. Facet returns the unique
// values directly. `exact: true` so the count we'll reuse for warnings
// matches what would be reported by an exhaustive walk.
const sources = new Set<string>()
let offset: string | number | null | Record<string, unknown> = null
const batchSize = 100
// Scroll through all points in the collection (only fetch source field)
do {
const scrollResult = await this.qdrant!.scroll(RagService.CONTENT_COLLECTION_NAME, {
limit: batchSize,
offset: offset,
with_payload: ['source'],
with_vector: false,
})
// Extract unique source values from payloads
scrollResult.points.forEach((point) => {
const source = point.payload?.source
if (source && typeof source === 'string') {
sources.add(source)
}
})
offset = scrollResult.next_page_offset || null
} while (offset !== null)
const facetResult = await this.qdrant!.facet(RagService.CONTENT_COLLECTION_NAME, {
key: 'source',
limit: RagService.FACET_SOURCE_LIMIT,
exact: true,
})
for (const hit of facetResult.hits) {
if (typeof hit.value === 'string') sources.add(hit.value)
}
// Union the Qdrant-derived list with the disk-backed file paths the
// state machine has tracked. Without this, files known to the scanner
@ -1120,20 +1140,90 @@ export class RagService {
)
}
return Array.from(sources).map((source) => {
const row = stateByPath.get(source)
return {
source,
state: row?.state ?? null,
chunksEmbedded: row?.chunks_embedded ?? 0,
}
})
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,
}
})
)
} 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
@ -1180,28 +1270,21 @@ export class RagService {
RagService.EMBEDDING_DIMENSION
)
// Per-source chunk count from a single scroll. We deliberately don't
// assume `kb_ingest_state.chunks_embedded` here so this PR stays
// independent of the state-machine PR (#888) — but a future cleanup can
// read from there for efficiency once both have landed.
// Per-source chunk count via Qdrant's facet API. Was a full scroll of
// every point in the collection, which on a fully-ingested NOMAD takes
// ~50s for a 3M-point KB just to count ~40 sources. Facet returns the
// distinct values + counts in a single call. `exact: true` because the
// counts feed Warning A's zero_chunks decision — approximate counts
// could mis-fire warnings near thresholds.
const chunksBySource = new Map<string, number>()
let offset: string | number | null | Record<string, unknown> = null
const batchSize = 100
do {
const scrollResult = await this.qdrant!.scroll(RagService.CONTENT_COLLECTION_NAME, {
limit: batchSize,
offset,
with_payload: ['source'],
with_vector: false,
})
for (const point of scrollResult.points) {
const source = point.payload?.source
if (source && typeof source === 'string') {
chunksBySource.set(source, (chunksBySource.get(source) ?? 0) + 1)
}
}
offset = scrollResult.next_page_offset || null
} while (offset !== null)
const facetResult = await this.qdrant!.facet(RagService.CONTENT_COLLECTION_NAME, {
key: 'source',
limit: RagService.FACET_SOURCE_LIMIT,
exact: true,
})
for (const hit of facetResult.hits) {
if (typeof hit.value === 'string') chunksBySource.set(hit.value, hit.count)
}
// Scan the filesystem the same way scanAndSyncStorage does so Warning A
// can fire on files with zero qdrant points (the headline "video-only
@ -1230,9 +1313,14 @@ export class RagService {
const fileSizeBytes = sizeByPath.get(source) ?? 0
const chunksInQdrant = chunksBySource.get(source) ?? 0
const fileName = source.split(/[/\\]/).pop() ?? source
// ignoreCatchAll: the partial_stall warning must only fire when the
// registry has a *specific* expectation for this file. The empty-pattern
// fallback (100 chunks/MB) over-predicts wildly for atypical ZIMs that
// are mostly PDFs/images/link-outs (e.g. military-medicine), producing
// false "ingestion stalled" warnings. Suppress Warning B in that case. (#913)
const expectedChunks =
fileSizeBytes > 0
? await KbRatioRegistry.estimateChunks(fileName, fileSizeBytes)
? await KbRatioRegistry.estimateChunks(fileName, fileSizeBytes, { ignoreCatchAll: true })
: null
const warnings = decideWarnings({ fileSizeBytes, chunksInQdrant, expectedChunks })
@ -1292,6 +1380,98 @@ export class RagService {
}
}
/**
* Reconcile the knowledge base after a curated content file (a ZIM) is
* replaced on disk by a newer downloaded version. Called from
* `RunDownloadJob.onComplete` once the new file is written and the old file
* has been deleted from disk.
*
* The decision (see `decideContentReindex` for the exhaustive, tested
* contract) mirrors the REPLACED file's prior indexed state instead of the
* global `rag.defaultIngestPolicy`. On a content update the user has already
* chosen whether this content belongs in the AI knowledge base, so we honor
* that choice in both directions:
*
* 1. (caller already deleted the outdated file from disk)
* 2. Qdrant not installed no-op (no knowledge base exists)
* 3. Old file WAS indexed + Qdrant
* running delete ONLY the old file's points
* (filter `source == oldFilePath`), drop
* its ingest-state row, and queue the new
* file for embedding BYPASSING the
* Always/Manual policy on purpose.
* 4. Old file NOT indexed no-op (respect the prior un-indexed /
* browse-only choice; do NOT auto-embed
* even under an Always policy)
* 5. Old indexed but Qdrant NOT
* currently running no-op. We can't remove the stale points,
* and a queued embed job could be cleared
* before Qdrant returns. Acting half-way is
* wasteful, so we defer entirely. Accepted
* tradeoff: the old file's points linger in
* Qdrant until a future re-index; they are
* NOT auto-reaped here.
*
* Point deletion is exact: ZIM chunks are stored with `source` equal to the
* full file path (see embedAndStoreText callers), so filtering on the old path
* can only ever remove the replaced file's own points.
*
* Returns the decision outcome for logging/tests; never throws on a Qdrant
* hiccup mid-reindex (logged and surfaced as `qdrant_not_running` semantics by
* the caller's try/catch).
*/
public async reconcileReplacedContentFile(params: {
oldFilePath: string
newFilePath: string
fileName: string
}): Promise<ReindexOutcome> {
const { oldFilePath, newFilePath, fileName } = params
const isReplacement = !!oldFilePath && oldFilePath !== newFilePath
// Step 2: is the knowledge base even installed? Short-circuits before any
// KB-state lookup, per the spec ordering.
const qdrantInstalled = isReplacement
? !!(await this.dockerService.getServiceURL(SERVICE_NAMES.QDRANT))
: false
// Steps 3/4: was the OUTDATED file actually indexed? The state row is the
// authoritative signal (RFC #883) — chunk presence alone can't distinguish
// a fully-indexed file from a stalled ingestion.
let oldFileWasIndexed = false
if (isReplacement && qdrantInstalled) {
const oldState = await KbIngestState.query().where('file_path', oldFilePath).first()
oldFileWasIndexed = oldState?.state === 'indexed'
}
// Step 5: only check liveness once we know we'd otherwise act. The health
// check is a real network round-trip, so we avoid it on the common no-op
// paths above.
let qdrantRunning = false
if (isReplacement && qdrantInstalled && oldFileWasIndexed) {
qdrantRunning = (await this.checkQdrantHealth()).online
}
const outcome = decideContentReindex({
isReplacement,
qdrantInstalled,
oldFileWasIndexed,
qdrantRunning,
})
if (outcome === 'reindex') {
// Order matters: remove the stale points and state row BEFORE queueing the
// new embed so a fresh index can't be conflated with the old one. Each step
// targets only `oldFilePath` / `newFilePath` — never another resource.
await this._deletePointsBySource(oldFilePath)
await KbIngestState.remove(oldFilePath)
const { EmbedFileJob } = await import('#jobs/embed_file_job')
await EmbedFileJob.dispatch({ fileName, filePath: newFilePath })
}
return outcome
}
public async discoverNomadDocs(force?: boolean): Promise<{ success: boolean; message: string }> {
try {
const README_PATH = join(process.cwd(), 'README.md')
@ -1548,22 +1728,17 @@ export class RagService {
)
// Collect every unique `source` already in Qdrant so we can skip files
// that have already been embedded.
// that have already been embedded. Facet returns the distinct values in
// a single call rather than scrolling the whole collection.
const sourcesInQdrant = new Set<string>()
let offset: string | number | null | Record<string, unknown> = null
do {
const scrollResult = await this.qdrant!.scroll(RagService.CONTENT_COLLECTION_NAME, {
limit: 100,
offset,
with_payload: ['source'],
with_vector: false,
})
scrollResult.points.forEach((point) => {
const source = point.payload?.source
if (source && typeof source === 'string') sourcesInQdrant.add(source)
})
offset = scrollResult.next_page_offset || null
} while (offset !== null)
const facetResult = await this.qdrant!.facet(RagService.CONTENT_COLLECTION_NAME, {
key: 'source',
limit: RagService.FACET_SOURCE_LIMIT,
exact: true,
})
for (const hit of facetResult.hits) {
if (typeof hit.value === 'string') sourcesInQdrant.add(hit.value)
}
logger.info(`[RAG] Found ${sourcesInQdrant.size} unique sources in Qdrant`)

View File

@ -1,4 +1,5 @@
import Service from '#models/service'
import InstalledResource from '#models/installed_resource'
import { inject } from '@adonisjs/core'
import { DockerService } from '#services/docker_service'
import { ServiceSlim } from '../../types/services.js'
@ -35,29 +36,54 @@ export class SystemService {
}
async getInternetStatus(): Promise<boolean> {
const DEFAULT_TEST_URL = 'https://1.1.1.1/cdn-cgi/trace'
// Primary endpoint stays Cloudflare's privacy-respecting utility endpoint.
// The fallbacks are hosts the application already contacts elsewhere
// (GitHub API for update checks, the Project N.O.M.A.D. API for release-note
// subscriptions), so no new third-party services are introduced. They exist
// to avoid false "offline" reports on networks that block 1.1.1.1.
const DEFAULT_TEST_URLS = [
'https://1.1.1.1/cdn-cgi/trace',
'https://api.github.com',
'https://api.projectnomad.us',
]
const MAX_ATTEMPTS = 3
let testUrl = DEFAULT_TEST_URL
let customTestUrl = env.get('INTERNET_STATUS_TEST_URL')?.trim()
let testUrls = DEFAULT_TEST_URLS
// check that customTestUrl is a valid URL, if provided
// 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 (customTestUrl && customTestUrl !== '') {
try {
new URL(customTestUrl)
testUrl = customTestUrl
testUrls = [customTestUrl]
} catch (error) {
logger.warn(
`Invalid INTERNET_STATUS_TEST_URL: ${customTestUrl}. Falling back to default URL.`
`Invalid internet status test URL: ${customTestUrl}. Falling back to default URLs.`
)
}
}
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
const res = await axios.get(testUrl, { timeout: 5000 })
return res.status === 200
// Probe all test 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 })
})
)
return true
} catch (error) {
// Promise.any only rejects (with an AggregateError) when every endpoint failed.
logger.warn(
`Internet status check attempt ${attempt}/${MAX_ATTEMPTS} failed: ${error instanceof Error ? error.message : error}`
)
@ -302,15 +328,26 @@ export class SystemService {
'installed',
'installation_status',
'ui_location',
'custom_url',
'friendly_name',
'description',
'icon',
'powered_by',
'display_order',
'container_image',
'available_update_version'
'available_update_version',
'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)
}
@ -334,10 +371,16 @@ export class SystemService {
installation_status: service.installation_status,
status: status ? status.status : 'unknown',
ui_location: service.ui_location || '',
custom_url: service.custom_url,
powered_by: service.powered_by,
display_order: service.display_order,
container_image: service.container_image,
available_update_version: service.available_update_version,
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,
})
}
@ -826,6 +869,31 @@ export class SystemService {
if (key === 'ai.assistantCustomName') {
invalidateAssistantNameCache()
}
// Re-enabling auto-update after a backoff-driven auto-disable clears the
// failure state so it gets a fresh start instead of immediately re-tripping.
if (key === 'autoUpdate.enabled' && (value === true || value === 'true')) {
await KVStore.setValue('autoUpdate.consecutiveFailures', '0')
await KVStore.clearValue('autoUpdate.autoDisabledReason')
}
// Re-enabling the global app auto-update master switch clears every app's
// per-app failure backoff so previously self-disabled apps get a fresh start.
if (key === 'appAutoUpdate.enabled' && (value === true || value === 'true')) {
await Service.query().update({
auto_update_consecutive_failures: 0,
auto_update_disabled_reason: null,
})
}
// Re-enabling content auto-update clears the feature-level backoff and every
// resource's per-resource backoff so previously self-disabled content gets a
// fresh start.
if (key === 'contentAutoUpdate.enabled' && (value === true || value === 'true')) {
await KVStore.setValue('contentAutoUpdate.consecutiveFailures', '0')
await KVStore.clearValue('contentAutoUpdate.autoDisabledReason')
await InstalledResource.query().update({
auto_update_consecutive_failures: 0,
auto_update_disabled_reason: null,
})
}
}
/**
@ -928,4 +996,83 @@ export class SystemService {
}
})
}
/**
* Check whether the host has enough free memory and disk to comfortably run an app.
* Returns an array of human-readable warning strings; an empty array means no concerns.
* These are advisory only the caller decides whether to block or warn.
*/
async checkResourceWarnings(minMemoryMB: number, minDiskMB: number): Promise<string[]> {
const warnings: string[] = []
try {
const mem = await si.mem()
const availableMB = Math.floor(mem.available / 1024 / 1024)
if (availableMB < minMemoryMB) {
warnings.push(
`Low memory: ${availableMB} MB available, this app recommends at least ${minMemoryMB} MB free.`
)
}
} catch (err: any) {
logger.warn(`[SystemService] checkResourceWarnings mem check failed: ${err.message}`)
}
try {
const storagePath = env.get('NOMAD_STORAGE_PATH', '/opt/project-nomad/storage')
const fsSizes = await si.fsSize()
// Find the filesystem whose mount point is the longest prefix of storagePath
const fs = fsSizes
.filter((f) => storagePath.startsWith(f.mount))
.sort((a, b) => b.mount.length - a.mount.length)[0]
if (fs) {
const availableDiskMB = Math.floor((fs.size - fs.used) / 1024 / 1024)
if (availableDiskMB < minDiskMB) {
warnings.push(
`Low disk space: ${availableDiskMB} MB available on ${fs.mount}, this app recommends at least ${minDiskMB} MB free.`
)
}
}
} catch (err: any) {
logger.warn(`[SystemService] checkResourceWarnings disk check failed: ${err.message}`)
}
return warnings
}
/**
* Return the next suggested host port for a custom app in the 8600+ range.
* Looks at existing custom service records and all Docker container port bindings.
*/
async getNextSuggestedCustomPort(): Promise<number> {
const CUSTOM_PORT_START = 8600
const occupied = new Set<number>()
try {
// Ports used by existing custom services in the DB
const customServices = await Service.query().where('is_custom', true)
for (const svc of customServices) {
const config = svc.container_config ? JSON.parse(svc.container_config) : null
const bindings = config?.HostConfig?.PortBindings ?? {}
for (const binding of Object.values(bindings) as any[]) {
const port = parseInt(binding?.[0]?.HostPort, 10)
if (!isNaN(port)) occupied.add(port)
}
}
// Ports used by any running Docker container in the 8600+ range
const containers = await this.dockerService.docker.listContainers({ all: true })
for (const c of containers) {
for (const p of c.Ports) {
if (p.PublicPort && p.PublicPort >= CUSTOM_PORT_START) occupied.add(p.PublicPort)
}
}
} catch (err: any) {
logger.warn(`[SystemService] getNextSuggestedCustomPort probe failed: ${err.message}`)
}
let candidate = CUSTOM_PORT_START
while (occupied.has(candidate)) candidate += 10
return candidate
}
}

View File

@ -18,9 +18,18 @@ export class SystemUpdateService {
private static LOG_FILE = join(SystemUpdateService.SHARED_DIR, 'update-log')
/**
* Requests a system update by creating a request file that the sidecar will detect
* Requests a system update by creating a request file that the sidecar will detect.
*
* @param options.targetTag - Explicit Docker image tag to install (e.g. "v1.33.2").
* When omitted, falls back to the cached `system.latestVersion` (manual-update
* behavior). Auto-update passes an eligibility-vetted tag here, which may differ
* from `system.latestVersion` when the newest release is a major bump.
* @param options.requester - Identifier recorded in the request file for auditing.
*/
async requestUpdate(): Promise<{ success: boolean; message: string }> {
async requestUpdate(options?: {
targetTag?: string
requester?: string
}): Promise<{ success: boolean; message: string }> {
try {
const currentStatus = this.getUpdateStatus()
if (currentStatus && !['idle', 'complete', 'error'].includes(currentStatus.stage)) {
@ -30,13 +39,18 @@ export class SystemUpdateService {
}
}
// Determine the Docker image tag to install.
const latestVersion = await KVStore.getValue('system.latestVersion')
// Determine the Docker image tag to install. Prefer an explicit caller-supplied
// tag; otherwise use the cached latest version.
let targetTag = options?.targetTag
if (!targetTag) {
const latestVersion = await KVStore.getValue('system.latestVersion')
targetTag = latestVersion ? `v${latestVersion}` : 'latest'
}
const requestData = {
requested_at: new Date().toISOString(),
requester: 'admin-api',
target_tag: latestVersion ? `v${latestVersion}` : 'latest',
requester: options?.requester ?? 'admin-api',
target_tag: targetTag,
}
await writeFile(SystemUpdateService.REQUEST_FILE, JSON.stringify(requestData, null, 2))

View File

@ -8,6 +8,7 @@ import * as cheerio from 'cheerio'
import { XMLParser } from 'fast-xml-parser'
import { isRawListRemoteZimFilesResponse, isRawRemoteZimFileEntry } from '../../util/zim.js'
import { findReplacedWikipediaFiles } from '../utils/zim_filename.js'
import { decideSupersededDeletion } from '../utils/superseded_resource.js'
import logger from '@adonisjs/core/services/logger'
import { DockerService } from './docker_service.js'
import { inject } from '@adonisjs/core'
@ -24,6 +25,7 @@ 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'
@ -358,6 +360,8 @@ export class ZimService {
}
// Create InstalledResource entries for downloaded files
const zimStorageDir = join(process.cwd(), ZIM_STORAGE_PATH)
let removedSupersededZim = false
for (const url of urls) {
// Skip Wikipedia files (managed separately)
if (url.includes('wikipedia_en_')) continue
@ -368,10 +372,17 @@ export class ZimService {
const parsed = CollectionManifestService.parseZimFilename(filename)
if (!parsed) continue
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
const filepath = join(zimStorageDir, filename)
const stats = await getFileStatsIfExists(filepath)
try {
// Capture the prior install for this resource_id BEFORE updateOrCreate
// overwrites it, so we know the old file path to clean up (#634).
const prior = await InstalledResource.query()
.where('resource_id', parsed.resource_id)
.where('resource_type', 'zim')
.first()
const { DateTime } = await import('luxon')
await InstalledResource.updateOrCreate(
{ resource_id: parsed.resource_id, resource_type: 'zim' },
@ -384,10 +395,170 @@ export class ZimService {
}
)
logger.info(`[ZimService] Created InstalledResource entry for: ${parsed.resource_id}`)
// Remove the superseded prior version's file if (and only if) every
// safety rail passes — see decideSupersededDeletion. The InstalledResource
// row already points at the new file, so we delete the old file directly
// (NOT via this.delete(), which would drop the row by resource_id).
const decision = decideSupersededDeletion({
existing: prior ? { file_path: prior.file_path, version: prior.version } : null,
newFilePath: filepath,
newVersion: parsed.version,
newFileExists: !!stats,
storageBaseDir: zimStorageDir,
})
if (decision.delete && decision.path) {
try {
await deleteFileIfExists(decision.path)
removedSupersededZim = true
logger.info(
`[ZimService] Removed superseded ${parsed.resource_id} file: ${decision.path}`
)
} catch (err) {
logger.warn(`[ZimService] Failed to remove superseded file ${decision.path}:`, err)
}
} else if (decision.reason !== 'first_install' && decision.reason !== 'same_file') {
logger.info(
`[ZimService] Kept prior ${parsed.resource_id} file (reason: ${decision.reason})`
)
}
} catch (error) {
logger.error(`[ZimService] Failed to create InstalledResource for ${filename}:`, error)
}
}
// If we removed any superseded ZIM, rebuild the Kiwix library so its XML no
// longer references the deleted file. The earlier rebuild in this flow ran
// while both versions were still on disk.
if (removedSupersededZim) {
try {
await new KiwixLibraryService().rebuildFromDisk()
logger.info('[ZimService] Rebuilt Kiwix library after removing superseded ZIM(s).')
} catch (err) {
logger.error('[ZimService] Failed to rebuild Kiwix library after cleanup:', err)
}
}
}
/**
* Rebuilds the kiwix library XML from whatever ZIM files are currently on disk.
*
* This is the manual counterpart to the automatic rebuilds that run after a
* download or delete. It exists for the sideload case: a user copies a .zim file
* onto the box (USB, SSH, network share) outside the download flow, and kiwix has
* no way to discover it without regenerating the library index.
*
* In library mode (--monitorLibrary) kiwix-serve hot-reloads the XML on its own, so
* no restart is needed. Only legacy glob-mode containers are restarted to pick up
* the change. Returns the book count before and after plus the number added.
*/
async rescanLibrary(): Promise<{ before: number; after: number; added: number }> {
const kiwixLibraryService = new KiwixLibraryService()
const before = await kiwixLibraryService.getBookCount()
const after = await kiwixLibraryService.rebuildFromDisk()
const isLegacy = await this.dockerService.isKiwixOnLegacyConfig()
if (isLegacy) {
logger.info('[ZimService] Kiwix in legacy mode — restarting container after rescan.')
await this.dockerService
.affectContainer(SERVICE_NAMES.KIWIX, 'restart')
.catch((error) => {
logger.error('[ZimService] Failed to restart KIWIX container after rescan:', error)
})
}
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> {
@ -426,6 +597,21 @@ 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

@ -0,0 +1,51 @@
import logger from '@adonisjs/core/services/logger'
import type InstalledResource from '#models/installed_resource'
/**
* Per-resource failure backoff for content (ZIM/map) auto-updates, shared by the
* three places that observe an auto-update's real lifecycle:
*
* - {@link ContentAutoUpdateService.attempt} a dispatch that fails to even
* enqueue (counts as a failure; no job runs so no terminal event follows).
* - `RunDownloadJob.onComplete` a download that actually finished (success).
* - the worker `failed` handler in `commands/queue/work.ts` a download that
* exhausted its retries (terminal failure).
*
* Kept in a dependency-light util (not on ContentAutoUpdateService) on purpose:
* RunDownloadJob is imported by CollectionUpdateService, which is imported by
* ContentAutoUpdateService, so importing the service back into the job would
* close an import cycle. Only the InstalledResource model and the logger are
* touched here.
*/
/** Genuine consecutive auto-update failures before a resource self-disables. */
export const MAX_CONSECUTIVE_FAILURES = 3
/** Clear a resource's failure backoff after a successful auto-update. */
export async function recordResourceUpdateSuccess(resource: InstalledResource): Promise<void> {
if (resource.auto_update_consecutive_failures === 0 && !resource.auto_update_disabled_reason) {
return
}
resource.auto_update_consecutive_failures = 0
resource.auto_update_disabled_reason = null
await resource.save()
}
/** Record an auto-update failure and self-disable the resource at the threshold. */
export async function recordResourceUpdateFailure(
resource: InstalledResource,
reason: string
): Promise<void> {
const failures = (resource.auto_update_consecutive_failures || 0) + 1
resource.auto_update_consecutive_failures = failures
if (failures >= MAX_CONSECUTIVE_FAILURES) {
resource.auto_update_disabled_reason = `Auto-update disabled after ${failures} consecutive failures. Last error: ${reason}`
logger.error(
`[ContentAutoUpdate] ${resource.resource_id} auto-disabled after ${failures} failures`
)
}
await resource.save()
logger.error(
`[ContentAutoUpdate] ${resource.resource_id} failure ${failures}/${MAX_CONSECUTIVE_FAILURES}: ${reason}`
)
}

View File

@ -0,0 +1,58 @@
/**
* Decision for reconciling the AI knowledge base (Qdrant) after a curated
* content file (a ZIM) is replaced by a newer downloaded version.
*
* This is the pure, I/O-free core of `RagService.reconcileReplacedContentFile`.
* Keeping the branching here (mirrors `decideScanAction` in
* `kb_ingest_decision.ts`) makes the contract exhaustively testable without a
* database or a live Qdrant.
*
* The logic deliberately MIRRORS the replaced file's prior indexed state rather
* than applying the global `rag.defaultIngestPolicy`: on a content *update* the
* user has already made an indexing choice for this content, so we honor it in
* both directions (re-index a previously-indexed file even under Manual; leave a
* previously-unindexed file alone even under Always). Fresh installs still go
* through the normal policy path those return `not_a_replacement` here.
*
* Outcomes (evaluated top-down, short-circuiting):
* - `not_a_replacement` no prior file, or the new file has the same path
* (same-version re-download). Caller defers to normal ingest policy.
* - `qdrant_not_installed` (step 2) no knowledge base exists; nothing to do.
* - `old_not_indexed` (step 4) the replaced file was never embedded
* (no state row, or state `indexed`); leave the new file un-indexed.
* - `qdrant_not_running` (step 5) the replaced file WAS indexed but Qdrant is
* currently offline. We do nothing: we can't remove the stale points, and a
* queued embed job could be dropped before Qdrant returns. Acting half-way is
* wasteful, so we defer entirely (accepted tradeoff: stale points linger).
* - `reindex` (step 3) the replaced file was indexed and Qdrant is running:
* delete ONLY the old file's points, drop its state row, and queue the new
* file for embedding.
*
* Note the install-before-indexed ordering: step 2 short-circuits before any KB
* state lookup, matching the spec.
*/
export type ReindexOutcome =
| 'not_a_replacement'
| 'qdrant_not_installed'
| 'old_not_indexed'
| 'qdrant_not_running'
| 'reindex'
export interface ContentReindexInput {
/** The replaced file existed AND its path differs from the new file's path. */
isReplacement: boolean
/** `nomad_qdrant` service exists (installed), regardless of running state. */
qdrantInstalled: boolean
/** The replaced file's `KbIngestState.state === 'indexed'`. */
oldFileWasIndexed: boolean
/** Qdrant answered a live health check (currently reachable). */
qdrantRunning: boolean
}
export function decideContentReindex(input: ContentReindexInput): ReindexOutcome {
if (!input.isReplacement) return 'not_a_replacement'
if (!input.qdrantInstalled) return 'qdrant_not_installed'
if (!input.oldFileWasIndexed) return 'old_not_indexed'
if (!input.qdrantRunning) return 'qdrant_not_running'
return 'reindex'
}

View File

@ -54,8 +54,8 @@ export async function doResumableDownload({
// Without this default, the validator below throws `MIME type is not allowed`
// and breaks all downloads from kiwix's primary host (#848).
const contentType =
headResponse.headers['content-type'] || 'application/octet-stream'
const totalBytes = parseInt(headResponse.headers['content-length'] || '0')
headResponse.headers['content-type']?.toString() || 'application/octet-stream'
const totalBytes = parseInt(headResponse.headers['content-length']?.toString() || '0', 10)
const supportsRangeRequests = headResponse.headers['accept-ranges'] === 'bytes'
// If allowedMimeTypes is provided, check content type

View File

@ -6,6 +6,22 @@ import { LSBlockDevice, NomadDiskInfoRaw } from '../../types/system.js'
export const ZIM_STORAGE_PATH = '/storage/zim'
export const KIWIX_LIBRARY_XML_PATH = '/storage/zim/kiwix-library.xml'
export const BOOKS_STORAGE_PATH = '/storage/books'
// Shared media root (Jellyfin reads it as /media; File Browser shows it as "media"). Per-type
// subfolders are pre-created on Jellyfin install — see _runPreinstallActions__Jellyfin.
export const MEDIA_STORAGE_PATH = '/storage/media'
export const JELLYFIN_MEDIA_SUBFOLDERS = ['Movies', 'TV Shows', 'Music', 'Photos']
// Empty Calibre library bundled into the admin image (see install/calibre-empty-library/).
// Seeded into storage/books on Calibre-Web install so it doesn't dead-end at db config.
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

@ -0,0 +1,83 @@
import logger from '@adonisjs/core/services/logger'
import type { ContainerRegistryService } from '#services/container_registry_service'
import type { SystemService } from '#services/system_service'
/**
* Shared pre-flight primitives for update flows (core app + installed apps).
* Kept framework-light (plain functions + injected service instances) so both
* {@link AutoUpdateService} and {@link AppAutoUpdateService} reuse one implementation.
*/
export type BlockerSeverity = 'skip' | 'failure'
export interface Blocker {
reason: string
severity: BlockerSeverity
}
export interface PreflightResult {
ok: boolean
blockers: Blocker[]
}
/** Require free space >= imageSize * factor to cover decompressed layers + headroom. */
export const DISK_SAFETY_FACTOR = 2
/** Conservative fallback when the registry image size can't be determined. */
export const MIN_FREE_BYTES = 5 * 1024 * 1024 * 1024 // 5 GiB
/** Free bytes on the root filesystem (best-effort, falls back to max available). */
export async function getFreeBytes(systemService: SystemService): Promise<number | null> {
const info = await systemService.getSystemInfo()
if (!info?.fsSize?.length) return null
const root = info.fsSize.find((f) => f.mount === '/')
if (root) return root.available
return Math.max(...info.fsSize.map((f) => f.available))
}
function gib(bytes: number): string {
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GiB`
}
/**
* Returns a `failure` disk blocker if free space is insufficient for the given
* image reference, otherwise null. Mirrors the core update's behavior: estimate
* the image's compressed download size from the registry manifest, require
* `size * DISK_SAFETY_FACTOR` (or {@link MIN_FREE_BYTES} when size is unknown),
* and never block on transient lookup errors (returns null on failure).
*
* @param image Full image reference INCLUDING tag (e.g. "ollama/ollama:0.23.2").
*/
export async function checkImageDiskSpace(params: {
image: string
hostArch: string
containerRegistryService: ContainerRegistryService
systemService: SystemService
}): Promise<Blocker | null> {
const { image, hostArch, containerRegistryService, systemService } = params
try {
const parsed = containerRegistryService.parseImageReference(image)
const imageSize = await containerRegistryService.getImageDownloadSize(
parsed,
parsed.tag,
hostArch
)
const required = imageSize !== null ? imageSize * DISK_SAFETY_FACTOR : MIN_FREE_BYTES
const free = await getFreeBytes(systemService)
if (free === null) {
logger.warn('[ImageDiskPreflight] Could not determine free disk space; skipping disk check')
return null
}
if (free < required) {
return {
reason: `Insufficient disk space: ${gib(free)} free, ${gib(required)} required`,
severity: 'failure',
}
}
return null
} catch (error) {
logger.warn(`[ImageDiskPreflight] Disk space check failed: ${error.message}`)
return null
}
}

View File

@ -66,10 +66,21 @@ export function estimateBatch(
*
* Returns `null` if no row matches and no empty-string fallback is present
* caller decides whether to surface "unknown" or use its own default.
*
* `ignoreCatchAll` excludes the empty-string catch-all row from matching, so a
* filename that only the fallback would have matched returns `null` instead.
* Callers that need a *specific* expectation (e.g. the partial_stall warning,
* which must not fire on atypical ZIMs the registry can't actually characterize)
* pass this; rough aggregate estimates (disk cost) leave it off. See #913.
*/
export function findChunksPerMb(filename: string, rows: RatioRow[]): number | null {
export function findChunksPerMb(
filename: string,
rows: RatioRow[],
opts: { ignoreCatchAll?: boolean } = {}
): number | null {
let best: RatioRow | null = null
for (const row of rows) {
if (opts.ignoreCatchAll && row.pattern === '') continue
if (!filename.startsWith(row.pattern)) continue
if (best === null || row.pattern.length > best.pattern.length) {
best = row
@ -88,9 +99,10 @@ export function findChunksPerMb(filename: string, rows: RatioRow[]): number | nu
export function estimateChunkCount(
filename: string,
fileSizeBytes: number,
rows: RatioRow[]
rows: RatioRow[],
opts: { ignoreCatchAll?: boolean } = {}
): number | null {
const ratio = findChunksPerMb(filename, rows)
const ratio = findChunksPerMb(filename, rows, opts)
if (ratio === null) return null
const megabytes = fileSizeBytes / (1024 * 1024)
return Math.round(ratio * megabytes)

View File

@ -0,0 +1,72 @@
import { resolve, sep } from 'node:path'
/**
* Decides whether a curated resource's PREVIOUSLY-installed file should be
* deleted now that a newer version has been downloaded (issue #634 old map
* and ZIM versions accumulated on disk indefinitely because only Wikipedia had
* version cleanup).
*
* This is intentionally a pure function so every safety rail is unit-testable
* without touching the DB or filesystem. The caller looks up the prior
* `InstalledResource` row, records the new version, then asks this whether the
* old file is safe to remove.
*
* Safety rails (a "delete" decision requires ALL of these):
* - There was a prior install for this exact resource_id (`existing` non-null).
* Untracked / sideloaded files have no row and are therefore never touched.
* - The old file path actually differs from the new one (a genuine version
* swap, not a re-download of the same file).
* - The new file is confirmed present on disk we never remove the old copy
* before the replacement is verified.
* - The new version is strictly newer than the recorded one, so a re-install
* or downgrade can't wipe a newer file.
* - The old path resolves to within the resource's storage directory, so a
* malformed DB value can't direct a delete outside the content store.
*/
export interface SupersededInputs {
/** Prior InstalledResource row for this resource_id, or null on first install. */
existing: { file_path: string; version: string } | null
/** Absolute path of the newly downloaded file. */
newFilePath: string
/** Version of the newly downloaded file (e.g. "2026-05"). */
newVersion: string
/** Whether the new file is confirmed present on disk. */
newFileExists: boolean
/** Absolute storage directory the old file must live under to be eligible. */
storageBaseDir: string
}
export type SupersededReason =
| 'first_install'
| 'same_file'
| 'new_file_missing'
| 'not_newer'
| 'outside_storage'
| 'superseded'
export interface SupersededDecision {
delete: boolean
/** Resolved old path to delete — set only when `delete` is true. */
path?: string
reason: SupersededReason
}
export function decideSupersededDeletion(inputs: SupersededInputs): SupersededDecision {
const { existing, newFilePath, newVersion, newFileExists, storageBaseDir } = inputs
if (!existing) return { delete: false, reason: 'first_install' }
if (existing.file_path === newFilePath) return { delete: false, reason: 'same_file' }
if (!newFileExists) return { delete: false, reason: 'new_file_missing' }
// Versions are zero-padded date strings (YYYY-MM / YYYY-MM-DD), so a lexical
// compare orders them correctly. Require strictly newer.
if (!(newVersion > existing.version)) return { delete: false, reason: 'not_newer' }
const resolvedOld = resolve(existing.file_path)
const base = resolve(storageBaseDir)
if (resolvedOld !== base && !resolvedOld.startsWith(base + sep)) {
return { delete: false, reason: 'outside_storage' }
}
return { delete: true, path: resolvedOld, reason: 'superseded' }
}

View File

@ -0,0 +1,35 @@
import { DateTime } from 'luxon'
/**
* Shared update-window helpers used by both the core auto-update
* ({@link AutoUpdateService}) and the per-app auto-update ({@link AppAutoUpdateService}).
*
* The window is interpreted in the container's local time (set via the TZ env var).
* Windows that wrap past midnight (start > end, e.g. 22:00-02:00) are supported.
*/
/** Parse an "HH:MM" 24-hour string into minutes-since-midnight, or null if malformed. */
export function parseWindowMinutes(hhmm: string): number | null {
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(hhmm)
if (!match) return null
return Number(match[1]) * 60 + Number(match[2])
}
/** Whether `now` falls inside the [windowStart, windowEnd) window (handles midnight wrap). */
export function isWithinWindow(
windowStart: string,
windowEnd: string,
now: DateTime = DateTime.now()
): boolean {
const start = parseWindowMinutes(windowStart)
const end = parseWindowMinutes(windowEnd)
if (start === null || end === null) return false
const current = now.hour * 60 + now.minute
if (start === end) return false // zero-length window
if (start < end) {
return current >= start && current < end
}
// Wraps midnight
return current >= start || current < end
}

View File

@ -14,25 +14,43 @@ import ipaddr from 'ipaddr.js'
*/
export function assertNotPrivateUrl(urlString: string): void {
const parsed = new URL(urlString)
const hostname = parsed.hostname.toLowerCase()
// `URL.hostname` strips the surrounding brackets from IPv6 literals
// (e.g. `http://[::1]/` → hostname `::1`), so IPv6 patterns must match
// the unbracketed form.
const blockedPatterns = [
/^localhost$/,
/^127\.\d+\.\d+\.\d+$/,
/^0\.0\.0\.0$/,
/^169\.254\.\d+\.\d+$/, // Link-local / cloud metadata
/^::1$/, // IPv6 loopback
/^fe80:/i, // IPv6 link-local
/^::ffff:/i, // IPv4-mapped IPv6 (e.g. ::ffff:7f00:1 = 127.0.0.1)
/^::$/, // IPv6 all-zeros (equivalent to 0.0.0.0)
]
// Normalize the host before classifying it:
// - lowercase for the `localhost` comparison
// - strip the surrounding brackets `URL.hostname` leaves on IPv6 literals
// (`http://[::1]/` → `::1`)
// - strip any trailing root dot(s): `localhost.` and `127.0.0.1.` resolve to
// the same target as the dotless form, so they must not slip past the
// checks below (#911).
const hostname = parsed.hostname
.toLowerCase()
.replace(/^\[|\]$/g, '')
.replace(/\.+$/, '')
if (blockedPatterns.some((re) => re.test(hostname))) {
if (hostname === 'localhost') {
throw new Error(`Download URL must not point to a loopback or link-local address: ${hostname}`)
}
// Anything that isn't a literal IP (DNS names, bare LAN hostnames like
// `nomad3`, external FQDNs) is allowed — LAN appliances need them, and DNS
// rebinding is a fetch-time concern outside this guard's scope. Classifying
// literal addresses with ipaddr.js (rather than a regex list) catches
// alternate encodings and normalizes address ranges correctly (#922).
if (!ipaddr.isValid(hostname)) return
let addr = ipaddr.parse(hostname)
if (addr.kind() === 'ipv6' && (addr as ipaddr.IPv6).isIPv4MappedAddress()) {
// e.g. ::ffff:127.0.0.1 — classify by the embedded IPv4 so a mapped
// loopback/link-local is blocked while a mapped public IP is allowed.
addr = (addr as ipaddr.IPv6).toIPv4Address()
}
const range = addr.range()
if (range === 'loopback' || range === 'linkLocal' || range === 'unspecified') {
throw new Error(
`Download URL must not point to a loopback or link-local address: ${addr.toNormalizedString()}`
)
}
}
/**

View File

@ -19,6 +19,12 @@ 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

@ -1,5 +1,6 @@
import vine from "@vinejs/vine";
import { SETTINGS_KEYS } from "../../constants/kv_store.js";
import type { KVStoreKey } from "../../types/kv_store.js";
export const getSettingSchema = vine.compile(vine.object({
key: vine.enum(SETTINGS_KEYS),
@ -8,4 +9,60 @@ export const getSettingSchema = vine.compile(vine.object({
export const updateSettingSchema = vine.compile(vine.object({
key: vine.enum(SETTINGS_KEYS),
value: vine.any().optional(),
}))
}))
const HHMM_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/
/**
* Validate the *value* for keys that have format constraints beyond the generic
* enum/any check (the generic validator only constrains the key). Returns an
* error message string when invalid, or null when the value is acceptable.
*/
export function validateSettingValue(key: KVStoreKey, value: unknown): string | null {
switch (key) {
case 'autoUpdate.windowStart':
case 'autoUpdate.windowEnd':
case 'contentAutoUpdate.windowStart':
case 'contentAutoUpdate.windowEnd':
if (typeof value !== 'string' || !HHMM_PATTERN.test(value)) {
return 'Time window values must be in 24-hour HH:MM format (e.g. "20:00").'
}
return null
case 'autoUpdate.cooloffHours':
case 'contentAutoUpdate.cooloffHours': {
const num = Number(value)
if (!Number.isInteger(num) || num < 0 || num > 8760) {
return 'Cool-off must be a whole number of hours between 0 and 8760.'
}
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)
if (!Number.isInteger(num) || num < 0) {
return 'The per-window data cap must be a whole number of bytes (0 = unlimited).'
}
return null
}
default:
return null
}
}

View File

@ -31,3 +31,142 @@ export const updateServiceValidator = vine.compile(
target_version: vine.string().trim(),
})
)
export const preflightValidator = vine.compile(
vine.object({
service_name: vine.string().trim(),
})
)
// Toggle per-app automatic updates (opt-in). The global master switch lives in
// the KVStore (`appAutoUpdate.enabled`) and flows through the settings endpoint.
export const setServiceAutoUpdateValidator = vine.compile(
vine.object({
service_name: vine.string().trim(),
enabled: vine.boolean(),
})
)
// Shared sub-schema for a volume bind mapping. A colon is Docker's bind delimiter
// (host:container:options) — forbid it in either field so a path can't smuggle in an
// extra segment that the guard reads as safe but Docker re-parses as a different mount.
const volumeSchema = vine.object({
host_path: vine.string().trim().regex(/^[^:]+$/),
container_path: vine.string().trim().regex(/^[^:]+$/),
})
// Environment variables must be KEY=value (value may be empty), matching Docker's Env format.
const envVarSchema = vine.string().trim().regex(/^[A-Za-z_][A-Za-z0-9_]*=[\s\S]*$/)
// Service-less preflight for the custom-app form: evaluates ports, volumes and image together.
export const preflightCustomValidator = vine.compile(
vine.object({
image: vine.string().trim().optional(),
ports: vine.array(vine.number().min(1).max(65535)).optional(),
volumes: vine.array(volumeSchema).optional(),
// When editing, ignore port conflicts caused by this app's own running container.
exclude_service: vine.string().trim().optional(),
})
)
export const customAppValidator = vine.compile(
vine.object({
friendly_name: vine.string().trim().minLength(1).maxLength(100),
image: vine.string().trim().minLength(1),
ports: vine
.array(
vine.object({
container: vine.number().min(1).max(65535),
host: vine.number().min(1024).max(65535),
})
)
.optional(),
volumes: vine.array(volumeSchema).optional(),
env: vine.array(envVarSchema).optional(),
category: vine
.enum(['productivity', 'media', 'security', 'networking', 'utility', 'ai', 'education', 'custom'])
.optional(),
icon: vine.string().trim().optional(),
// Optional resource caps (advanced). Default caps are applied when omitted.
memory_mb: vine.number().min(64).optional(),
cpus: vine.number().min(0.1).max(64).optional(),
// When true, bypass advisory preflight (port conflicts / guard warnings) and install anyway.
force: vine.boolean().optional(),
})
)
// Set or clear an app's custom launch URL. A null/empty value clears the override; a non-empty
// value is normalized + validated to a http(s) URL by normalizeCustomUrl in the controller.
export const setServiceCustomUrlValidator = vine.compile(
vine.object({
service_name: vine.string().trim(),
custom_url: vine.string().trim().nullable(),
})
)
/**
* Normalize a user-supplied custom app URL (backend twin of the inertia helper in
* lib/navigation.ts). Accepts a bare host or a full URL; prepends http:// when no scheme is
* present. Returns the normalized href, or null when empty (clears the override) or not a valid
* http(s) URL. Restricting to http/https blocks javascript:/data: from ever being stored.
*/
export function normalizeCustomUrl(input: string | null | undefined): string | null {
const trimmed = (input ?? '').trim()
if (!trimmed) return null
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`
try {
const url = new URL(withScheme)
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
return url.href
} catch {
return null
}
}
export const deleteCustomAppValidator = 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 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(),
})
)
// Reconfigure an existing custom app: the create shape plus the target service_name.
export const updateCustomAppValidator = vine.compile(
vine.object({
service_name: vine.string().trim(),
friendly_name: vine.string().trim().minLength(1).maxLength(100),
image: vine.string().trim().minLength(1),
ports: vine
.array(
vine.object({
container: vine.number().min(1).max(65535),
host: vine.number().min(1024).max(65535),
})
)
.optional(),
volumes: vine.array(volumeSchema).optional(),
env: vine.array(envVarSchema).optional(),
category: vine
.enum(['productivity', 'media', 'security', 'networking', 'utility', 'ai', 'education', 'custom'])
.optional(),
icon: vine.string().trim().optional(),
memory_mb: vine.number().min(64).optional(),
cpus: vine.number().min(0.1).max(64).optional(),
force: vine.boolean().optional(),
})
)

View File

@ -0,0 +1,227 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
/**
* Exercise the app auto-update decision logic WITHOUT ever triggering a real update.
*
* # Prove the per-app eligibility + window logic deterministically (no DB/Docker):
* node ace app-auto-update:dry-run --scenarios
*
* # Show, against the live DB, which opted-in apps WOULD update right now:
* node ace app-auto-update:dry-run
*/
export default class AppAutoUpdateDryRun extends BaseCommand {
static commandName = 'app-auto-update:dry-run'
static description = 'Dry-run the app auto-update decision logic (never triggers an update)'
@flags.boolean({ description: 'Run the built-in deterministic scenario suite and exit' })
declare scenarios: boolean
static options: CommandOptions = {
startApp: true,
}
async run() {
const { DateTime } = await import('luxon')
const { DockerService } = await import('#services/docker_service')
const { DownloadService } = await import('#services/download_service')
const { SystemService } = await import('#services/system_service')
const { ContainerRegistryService } = await import('#services/container_registry_service')
const { QueueService } = await import('#services/queue_service')
const { AppAutoUpdateService } = await import('#services/app_auto_update_service')
const { isWithinWindow } = await import('../../app/utils/update_window.js')
const dockerService = new DockerService()
const svc = new AppAutoUpdateService(
dockerService,
new DownloadService(QueueService.getInstance()),
new SystemService(dockerService),
new ContainerRegistryService()
)
if (this.scenarios) {
const ok = this.runScenarios(svc, DateTime, isWithinWindow)
if (!ok) this.exitCode = 1
return
}
// --- Live read-only snapshot (no update triggered) ----------------------
const status = await svc.getStatus()
this.logger.log('')
this.logger.log(` Master switch : ${status.enabled ? 'enabled' : 'disabled'}`)
this.logger.log(
` Window : ${status.windowStart}-${status.windowEnd} ` +
`(currently ${status.withinWindow ? 'inside' : 'outside'})`
)
this.logger.log(` Cool-off hours : ${status.cooloffHours}`)
this.logger.log('')
if (status.apps.length === 0) {
this.logger.info('No apps are opted into auto-update.')
return
}
this.logger.log('Opted-in apps:')
for (const app of status.apps) {
const tag = app.eligible ? this.colors.green('WOULD UPDATE') : this.colors.dim('skip')
this.logger.log(
` ${tag} ${app.friendly_name || app.service_name}: ${app.current_version}` +
`${app.available_update_version ? ' → ' + app.available_update_version : ''}${app.reason}`
)
}
}
/**
* Deterministic acceptance suite over the pure decision helpers no DB or Docker.
* Uses ContainerRegistryService.parseImageReference (pure) via appEligibility.
*/
private runScenarios(svc: any, DateTime: any, isWithinWindow: any): boolean {
const now = DateTime.fromISO('2026-06-04T12:00:00Z')
const daysAgo = (d: number) => now.minus({ days: d })
const hoursAgo = (h: number) => now.minus({ hours: h })
const mk = (o: Record<string, any>) => ({
service_name: 'nomad_test',
container_image: 'ollama/ollama:0.18.1',
available_update_version: null,
available_update_first_seen_at: null,
auto_update_disabled_reason: null,
...o,
})
type Case = { name: string; service: any; cooloff: number; expect: boolean }
const cases: Case[] = [
{ name: 'no update → not eligible', service: mk({}), cooloff: 72, expect: false },
{
name: 'major bump → not eligible',
service: mk({
available_update_version: '1.0.0',
available_update_first_seen_at: daysAgo(10),
}),
cooloff: 72,
expect: false,
},
{
name: 'minor newer inside cool-off → not eligible',
service: mk({
available_update_version: '0.19.0',
available_update_first_seen_at: hoursAgo(10),
}),
cooloff: 72,
expect: false,
},
{
name: 'minor newer past cool-off → eligible',
service: mk({
available_update_version: '0.19.0',
available_update_first_seen_at: daysAgo(5),
}),
cooloff: 72,
expect: true,
},
{
name: 'null first-seen → not eligible',
service: mk({ available_update_version: '0.19.0', available_update_first_seen_at: null }),
cooloff: 72,
expect: false,
},
{
name: 'self-disabled → not eligible',
service: mk({
available_update_version: '0.19.0',
available_update_first_seen_at: daysAgo(30),
auto_update_disabled_reason: 'disabled',
}),
cooloff: 72,
expect: false,
},
{
name: ':latest pinned → not eligible',
service: mk({
container_image: 'foo/bar:latest',
available_update_version: '1.2.3',
available_update_first_seen_at: daysAgo(30),
}),
cooloff: 72,
expect: false,
},
{
name: 'cool-off 0 applies immediately',
service: mk({
available_update_version: '0.18.2',
available_update_first_seen_at: hoursAgo(1),
}),
cooloff: 0,
expect: true,
},
]
type WinCase = { name: string; start: string; end: string; at: string; expect: boolean }
const at = (hhmm: string) => `2026-06-04T${hhmm}:00`
const windows: WinCase[] = [
{
name: 'normal 20:00-23:00 @ 21:00 → in',
start: '20:00',
end: '23:00',
at: at('21:00'),
expect: true,
},
{
name: 'normal 20:00-23:00 @ 19:00 → out',
start: '20:00',
end: '23:00',
at: at('19:00'),
expect: false,
},
{
name: 'wrap 22:00-02:00 @ 01:00 → in',
start: '22:00',
end: '02:00',
at: at('01:00'),
expect: true,
},
{
name: 'wrap 22:00-02:00 @ 12:00 → out',
start: '22:00',
end: '02:00',
at: at('12:00'),
expect: false,
},
]
let passed = 0
let failed = 0
this.logger.log('')
this.logger.log('Eligibility scenarios:')
for (const c of cases) {
const got = svc.appEligibility(c.service, c.cooloff, now).eligible
const ok = got === c.expect
this.report(ok, `${c.name} (expected ${c.expect}, got ${got})`)
ok ? passed++ : failed++
}
this.logger.log('')
this.logger.log('Window scenarios:')
for (const c of windows) {
const got = isWithinWindow(c.start, c.end, DateTime.fromISO(c.at))
const ok = got === c.expect
this.report(ok, `${c.name} (expected ${c.expect}, got ${got})`)
ok ? passed++ : failed++
}
this.logger.log('')
if (failed === 0) {
this.logger.success(`All ${passed} scenarios passed`)
} else {
this.logger.error(`${failed} scenario(s) failed, ${passed} passed`)
}
return failed === 0
}
private report(ok: boolean, message: string) {
if (ok) {
this.logger.log(` ${this.colors.green('✓')} ${message}`)
} else {
this.logger.log(` ${this.colors.red('✗')} ${message}`)
}
}
}

View File

@ -0,0 +1,302 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import { readFile } from 'node:fs/promises'
/**
* Exercise the auto-update decision pipeline WITHOUT ever triggering a real update.
*
* # Prove the core logic deterministically (no network/DB/Docker):
* node ace auto-update:dry-run --scenarios
*
* # Simulate "what would happen if I were running 1.32.0 right now"
* # against the live GitHub releases feed and real pre-flight checks:
* node ace auto-update:dry-run --current=1.32.0 --force-enabled
*
* # Fully offline simulation with a canned release list + fixed clock:
* node ace auto-update:dry-run --current=1.32.0 --force-enabled \
* --releases-file=./fixtures/releases.json --now=2026-06-04T21:00:00Z \
* --window-start=20:00 --window-end=23:00 --skip-preflight
*/
export default class AutoUpdateDryRun extends BaseCommand {
static commandName = 'auto-update:dry-run'
static description = 'Dry-run the auto-update decision pipeline (never triggers an update)'
@flags.boolean({ description: 'Run the built-in deterministic scenario suite and exit' })
declare scenarios: boolean
@flags.string({ description: 'Simulate this currently-running version (e.g. 1.32.0)' })
declare current: string
@flags.boolean({ description: 'Ignore the persisted enabled setting and treat as enabled' })
declare forceEnabled: boolean
@flags.string({ description: 'Override cool-off hours' })
declare cooloff: string
@flags.string({ description: 'Override window start (HH:MM)' })
declare windowStart: string
@flags.string({ description: 'Override window end (HH:MM)' })
declare windowEnd: string
@flags.string({ description: 'Simulate the clock at this ISO timestamp' })
declare now: string
@flags.string({ description: 'Path to a JSON file with a GitHub releases array (offline)' })
declare releasesFile: string
@flags.boolean({ description: 'Bypass Docker/disk/queue pre-flight checks' })
declare skipPreflight: boolean
static options: CommandOptions = {
startApp: true,
}
async run() {
const { DateTime } = await import('luxon')
const { DockerService } = await import('#services/docker_service')
const { DownloadService } = await import('#services/download_service')
const { SystemService } = await import('#services/system_service')
const { SystemUpdateService } = await import('#services/system_update_service')
const { ContainerRegistryService } = await import('#services/container_registry_service')
const { QueueService } = await import('#services/queue_service')
const { AutoUpdateService } = await import('#services/auto_update_service')
const dockerService = new DockerService()
const svc = new AutoUpdateService(
dockerService,
new DownloadService(QueueService.getInstance()),
new SystemService(dockerService),
new SystemUpdateService(),
new ContainerRegistryService()
)
if (this.scenarios) {
const ok = this.runScenarios(svc, DateTime)
if (!ok) {
this.exitCode = 1
}
return
}
// --- Live / simulated single dry run ------------------------------------
const overrides: Record<string, any> = {}
if (this.current) overrides.currentVersion = this.current
if (this.forceEnabled) overrides.forceEnabled = true
if (this.cooloff) overrides.cooloffHours = Number(this.cooloff)
if (this.windowStart) overrides.windowStart = this.windowStart
if (this.windowEnd) overrides.windowEnd = this.windowEnd
if (this.skipPreflight) overrides.skipPreflight = true
if (this.now) overrides.now = DateTime.fromISO(this.now)
if (this.releasesFile) {
const raw = await readFile(this.releasesFile, 'utf-8')
overrides.releases = JSON.parse(raw)
}
this.logger.info('Running auto-update dry run (no update will be triggered)...')
const decision = await svc.dryRun(overrides)
this.logger.log('')
this.logger.log(` Current version : ${decision.currentVersion}`)
this.logger.log(` Enabled : ${decision.enabled}`)
this.logger.log(
` Window : ${decision.config.windowStart}-${decision.config.windowEnd} ` +
`(currently ${decision.withinWindow ? 'inside' : 'outside'})`
)
this.logger.log(` Cool-off hours : ${decision.config.cooloffHours}`)
this.logger.log(
` Eligible target : ${decision.eligibleTarget ? decision.eligibleTarget.tag + ' (published ' + decision.eligibleTarget.publishedAt + ')' : '—'}`
)
if (decision.preflight) {
if (decision.preflight.ok) {
this.logger.log(` Pre-flight : ok`)
} else {
this.logger.log(` Pre-flight : BLOCKED`)
for (const b of decision.preflight.blockers) {
this.logger.log(` - [${b.severity}] ${b.reason}`)
}
}
} else {
this.logger.log(` Pre-flight : (not reached)`)
}
this.logger.log('')
const verdict =
decision.outcome === 'ready'
? `WOULD UPDATE → ${decision.eligibleTarget!.tag}`
: `WOULD NOT UPDATE (${decision.outcome}): ${decision.reason}`
if (decision.outcome === 'ready') {
this.logger.success(verdict)
} else {
this.logger.info(verdict)
}
}
/**
* Deterministic acceptance suite over the pure decision helpers no network,
* DB, or Docker. Proves every branch reviewers care about.
*/
private runScenarios(svc: any, DateTime: any): boolean {
const NOW = '2026-06-04T12:00:00Z'
const now = DateTime.fromISO(NOW)
const daysAgo = (d: number) => now.minus({ days: d }).toISO()
const hoursAgo = (h: number) => now.minus({ hours: h }).toISO()
const rel = (tag: string, published: string, extra: object = {}) => ({
tag_name: tag,
published_at: published,
...extra,
})
type EligCase = {
name: string
releases: any[]
current: string
cooloff: number
expect: string | null
}
const eligibility: EligCase[] = [
{
name: 'only a major bump is newer → none (major requires manual)',
releases: [rel('v2.0.0', daysAgo(10))],
current: '1.32.0',
cooloff: 72,
expect: null,
},
{
name: 'same-major minor newer but inside cool-off → none',
releases: [rel('v1.33.0', hoursAgo(10))],
current: '1.32.0',
cooloff: 72,
expect: null,
},
{
name: 'same-major patch past cool-off → selected',
releases: [rel('v1.32.1', daysAgo(5))],
current: '1.32.0',
cooloff: 72,
expect: '1.32.1',
},
{
name: 'mixed: newest same-major past cool-off wins; major/in-cooloff/prerelease ignored',
releases: [
rel('v2.0.0', daysAgo(30)),
rel('v1.34.0', hoursAgo(5)),
rel('v1.33.2', daysAgo(4)),
rel('v1.33.5', daysAgo(1), { prerelease: true }),
rel('v1.33.0', daysAgo(8)),
],
current: '1.32.9',
cooloff: 72,
expect: '1.33.2',
},
{
name: 'draft releases ignored',
releases: [rel('v1.33.0', daysAgo(5), { draft: true })],
current: '1.32.0',
cooloff: 72,
expect: null,
},
{
name: 'malformed tag with injection chars → ignored (M2)',
releases: [rel('v1.33.0|; e reboot', daysAgo(10))],
current: '1.32.0',
cooloff: 72,
expect: null,
},
{
name: 'dev build never updates',
releases: [rel('v1.33.0', daysAgo(10))],
current: 'dev',
cooloff: 72,
expect: null,
},
{
name: 'cool-off of 0 applies immediately',
releases: [rel('v1.32.1', hoursAgo(1))],
current: '1.32.0',
cooloff: 0,
expect: '1.32.1',
},
]
type WinCase = { name: string; start: string; end: string; at: string; expect: boolean }
const at = (hhmm: string) => `2026-06-04T${hhmm}:00`
const windows: WinCase[] = [
{
name: 'normal 20:00-23:00 @ 21:00 → in',
start: '20:00',
end: '23:00',
at: at('21:00'),
expect: true,
},
{
name: 'normal 20:00-23:00 @ 19:00 → out',
start: '20:00',
end: '23:00',
at: at('19:00'),
expect: false,
},
{
name: 'wrap 22:00-02:00 @ 23:00 → in',
start: '22:00',
end: '02:00',
at: at('23:00'),
expect: true,
},
{
name: 'wrap 22:00-02:00 @ 01:00 → in',
start: '22:00',
end: '02:00',
at: at('01:00'),
expect: true,
},
{
name: 'wrap 22:00-02:00 @ 12:00 → out',
start: '22:00',
end: '02:00',
at: at('12:00'),
expect: false,
},
]
let passed = 0
let failed = 0
this.logger.log('')
this.logger.log('Eligibility scenarios:')
for (const c of eligibility) {
const got = svc.selectEligibleTarget(c.releases, c.current, c.cooloff, now)
const gotVersion = got ? got.version : null
const ok = gotVersion === c.expect
this.report(ok, `${c.name} (expected ${c.expect ?? 'none'}, got ${gotVersion ?? 'none'})`)
ok ? passed++ : failed++
}
this.logger.log('')
this.logger.log('Window scenarios:')
for (const c of windows) {
const cfg = { enabled: true, windowStart: c.start, windowEnd: c.end, cooloffHours: 72 }
const got = svc.isWithinWindow(cfg, DateTime.fromISO(c.at))
const ok = got === c.expect
this.report(ok, `${c.name} (expected ${c.expect}, got ${got})`)
ok ? passed++ : failed++
}
this.logger.log('')
if (failed === 0) {
this.logger.success(`All ${passed} scenarios passed`)
} else {
this.logger.error(`${failed} scenario(s) failed, ${passed} passed`)
}
return failed === 0
}
private report(ok: boolean, message: string) {
if (ok) {
this.logger.log(` ${this.colors.green('✓')} ${message}`)
} else {
this.logger.log(` ${this.colors.red('✗')} ${message}`)
}
}
}

View File

@ -0,0 +1,218 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import { isWithinWindow } from '../../app/utils/update_window.js'
/**
* Exercise the content auto-update decision pipeline WITHOUT ever dispatching a
* real download.
*
* # Prove the core selection/eligibility/window logic deterministically
* # (no network/DB):
* node ace content-auto-update:dry-run --scenarios
*
* # Evaluate what the next run would do against the currently-persisted
* # available-update state (run a "Check for Content Updates" first to refresh
* # it), forcing the feature on and overriding the cap:
* node ace content-auto-update:dry-run --force-enabled --cap=20 --window-start=00:00 --window-end=23:59
*/
export default class ContentAutoUpdateDryRun extends BaseCommand {
static commandName = 'content-auto-update:dry-run'
static description = 'Dry-run the content auto-update decision pipeline (never dispatches a download)'
@flags.boolean({ description: 'Run the built-in deterministic scenario suite and exit' })
declare scenarios: boolean
@flags.boolean({ description: 'Ignore the persisted enabled setting and treat as enabled' })
declare forceEnabled: boolean
@flags.string({ description: 'Override cool-off hours' })
declare cooloff: string
@flags.string({ description: 'Override window start (HH:MM)' })
declare windowStart: string
@flags.string({ description: 'Override window end (HH:MM)' })
declare windowEnd: string
@flags.string({ description: 'Override per-window data cap in GB (0 = unlimited)' })
declare cap: string
@flags.string({ description: 'Override bytes already used this window' })
declare usedBytes: string
@flags.string({ description: 'Simulate the clock at this ISO timestamp' })
declare now: string
static options: CommandOptions = {
startApp: true,
}
async run() {
const { DateTime } = await import('luxon')
const { DownloadService } = await import('#services/download_service')
const { QueueService } = await import('#services/queue_service')
const { ContentAutoUpdateService } = await import('#services/content_auto_update_service')
const svc = new ContentAutoUpdateService(new DownloadService(QueueService.getInstance()))
if (this.scenarios) {
const ok = this.runScenarios(svc, DateTime)
if (!ok) this.exitCode = 1
return
}
const BYTES_PER_GB = 1024 * 1024 * 1024
const overrides: Record<string, any> = {}
if (this.forceEnabled) overrides.forceEnabled = true
if (this.cooloff) overrides.cooloffHours = Number(this.cooloff)
if (this.windowStart) overrides.windowStart = this.windowStart
if (this.windowEnd) overrides.windowEnd = this.windowEnd
if (this.cap) overrides.maxBytesPerWindow = Math.round(Number(this.cap) * BYTES_PER_GB)
if (this.usedBytes) overrides.windowBytesUsed = Number(this.usedBytes)
if (this.now) overrides.now = DateTime.fromISO(this.now)
this.logger.info('Running content auto-update dry run (no download will be dispatched)...')
const d = await svc.dryRun(overrides)
this.logger.log('')
this.logger.log(` Enabled : ${d.enabled}`)
this.logger.log(
` Window : ${d.config.windowStart}-${d.config.windowEnd} ` +
`(currently ${d.withinWindow ? 'inside' : 'outside'})`
)
this.logger.log(` Cool-off hours : ${d.config.cooloffHours}`)
this.logger.log(
` Data cap : ${d.config.maxBytesPerWindow > 0 ? d.config.maxBytesPerWindow + ' bytes' : 'unlimited'}`
)
this.logger.log(` Eligible : ${d.eligibleCount}`)
this.logger.log(` Would start : ${d.selection.selected.map((c) => c.resource.resource_id).join(', ') || '—'}`)
this.logger.log(
` Skipped (cap) : ${d.selection.skippedOversize.map((c) => c.resource.resource_id).join(', ') || '—'}`
)
this.logger.log(
` Deferred (budget): ${d.selection.deferred.map((c) => c.resource.resource_id).join(', ') || '—'}`
)
this.logger.log('')
}
/**
* Deterministic acceptance suite over the pure decision helpers no network
* or dispatch. Mirrors the per-resource eligibility, cap selection, and window
* branches reviewers care about.
*/
private runScenarios(svc: any, DateTime: any): boolean {
const now = DateTime.fromISO('2026-06-04T03:00:00Z')
const daysAgo = (d: number) => now.minus({ days: d })
const hoursAgo = (h: number) => now.minus({ hours: h })
const res = (o: Record<string, any> = {}) => ({
resource_id: 'res',
version: '2024-01',
available_update_version: null,
available_update_size_bytes: null,
available_update_first_seen_at: null,
auto_update_disabled_reason: null,
auto_update_consecutive_failures: 0,
installed_at: daysAgo(100),
...o,
})
const cand = (id: string, size: number, installedAt: any = daysAgo(100)) => ({
resource: res({ resource_id: id }),
version: '2024-06',
download_url: `(test)`,
size_bytes: size,
installed_at: installedAt,
})
let passed = 0
let failed = 0
const report = (ok: boolean, message: string) => {
this.logger.log(` ${ok ? this.colors.green('✓') : this.colors.red('✗')} ${message}`)
ok ? passed++ : failed++
}
this.logger.log('')
this.logger.log('Eligibility scenarios:')
report(
svc.resourceEligibility(res(), 72, now).eligible === false,
'no available update → not eligible'
)
report(
svc.resourceEligibility(
res({ available_update_version: '2024-06', available_update_first_seen_at: hoursAgo(10) }),
72,
now
).eligible === false,
'inside cool-off → not eligible'
)
report(
svc.resourceEligibility(
res({ available_update_version: '2024-06', available_update_first_seen_at: daysAgo(5) }),
72,
now
).eligible === true,
'past cool-off → eligible'
)
report(
svc.resourceEligibility(
res({
available_update_version: '2024-06',
available_update_first_seen_at: daysAgo(30),
auto_update_disabled_reason: 'disabled',
}),
72,
now
).eligible === false,
'self-disabled → not eligible'
)
this.logger.log('')
this.logger.log('Cap selection scenarios:')
{
const s = svc.selectUnderCap([cand('a', 1000), cand('b', 2000)], 10000, 0)
report(s.selected.length === 2, 'under cap selects all')
}
{
const s = svc.selectUnderCap([cand('huge', 50000)], 20000, 0)
report(
s.selected.length === 0 && s.skippedOversize.length === 1,
'oversize file → skipped, never selected'
)
}
{
const s = svc.selectUnderCap([cand('mid', 8000)], 10000, 5000)
report(s.selected.length === 0 && s.deferred.length === 1, 'over remaining budget → deferred')
}
{
const s = svc.selectUnderCap([cand('a', 0)], 10000, 0)
report(s.selected.length === 0 && s.deferred.length === 1, 'unknown size → deferred')
}
{
const s = svc.selectUnderCap([cand('big', 9_999_999_999)], 0, 0)
report(s.selected.length === 1, 'cap 0 → unlimited')
}
this.logger.log('')
this.logger.log('Window scenarios:')
report(
isWithinWindow('02:00', '05:00', DateTime.fromISO('2026-06-04T03:00:00')) === true,
'normal 02:00-05:00 @ 03:00 → in'
)
report(
isWithinWindow('22:00', '02:00', DateTime.fromISO('2026-06-04T01:00:00')) === true,
'wrap 22:00-02:00 @ 01:00 → in'
)
report(
isWithinWindow('22:00', '02:00', DateTime.fromISO('2026-06-04T12:00:00')) === false,
'wrap 22:00-02:00 @ 12:00 → out'
)
this.logger.log('')
if (failed === 0) {
this.logger.success(`All ${passed} scenarios passed`)
} else {
this.logger.error(`${failed} scenario(s) failed, ${passed} passed`)
}
return failed === 0
}
}

View File

@ -9,6 +9,9 @@ import { RunBenchmarkJob } from '#jobs/run_benchmark_job'
import { EmbedFileJob } from '#jobs/embed_file_job'
import { CheckUpdateJob } from '#jobs/check_update_job'
import { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job'
import { AutoUpdateJob } from '#jobs/auto_update_job'
import { AppAutoUpdateJob } from '#jobs/app_auto_update_job'
import { ContentAutoUpdateJob } from '#jobs/content_auto_update_job'
export default class QueueWork extends BaseCommand {
static commandName = 'queue:work'
@ -90,6 +93,36 @@ export default class QueueWork extends BaseCommand {
)
}
}
// Terminal failure of an AUTO content update → advance that resource's
// backoff (self-disables after MAX_CONSECUTIVE_FAILURES). BullMQ emits
// `failed` on every attempt, so gate on the final attempt to count each
// doomed download once, not once per retry. Manual downloads (auto !== true)
// are deliberately excluded.
const meta = job?.data?.resourceMetadata
const isTerminal = (job?.attemptsMade ?? 0) >= (job?.opts?.attempts ?? 1)
if (job?.name === RunDownloadJob.key && meta?.auto === true && isTerminal) {
try {
const { default: InstalledResource } = await import('#models/installed_resource')
const { recordResourceUpdateFailure } = await import(
'../../app/utils/content_auto_update_backoff.js'
)
const resource = await InstalledResource.query()
.where('resource_id', meta.resource_id)
.where('resource_type', job.data.filetype)
.first()
if (resource) {
await recordResourceUpdateFailure(
resource,
err instanceof Error ? err.message : String(err)
)
}
} catch (e: any) {
this.logger.error(
`[${queueName}] Failed to record content auto-update backoff: ${e.message}`
)
}
}
})
worker.on('completed', (job) => {
@ -103,6 +136,9 @@ export default class QueueWork extends BaseCommand {
// Schedule nightly update checks (idempotent, will persist over restarts)
await CheckUpdateJob.scheduleNightly()
await CheckServiceUpdatesJob.scheduleNightly()
await AutoUpdateJob.schedule()
await AppAutoUpdateJob.schedule()
await ContentAutoUpdateJob.schedule()
// Safety net: log unhandled rejections instead of crashing the worker process.
// Individual job errors are already caught by BullMQ; this catches anything that
@ -133,6 +169,9 @@ export default class QueueWork extends BaseCommand {
handlers.set(EmbedFileJob.key, new EmbedFileJob())
handlers.set(CheckUpdateJob.key, new CheckUpdateJob())
handlers.set(CheckServiceUpdatesJob.key, new CheckServiceUpdatesJob())
handlers.set(AutoUpdateJob.key, new AutoUpdateJob())
handlers.set(AppAutoUpdateJob.key, new AppAutoUpdateJob())
handlers.set(ContentAutoUpdateJob.key, new ContentAutoUpdateJob())
queues.set(RunDownloadJob.key, RunDownloadJob.queue)
queues.set(RunExtractPmtilesJob.key, RunExtractPmtilesJob.queue)
@ -141,6 +180,9 @@ export default class QueueWork extends BaseCommand {
queues.set(EmbedFileJob.key, EmbedFileJob.queue)
queues.set(CheckUpdateJob.key, CheckUpdateJob.queue)
queues.set(CheckServiceUpdatesJob.key, CheckServiceUpdatesJob.queue)
queues.set(AutoUpdateJob.key, AutoUpdateJob.queue)
queues.set(AppAutoUpdateJob.key, AppAutoUpdateJob.queue)
queues.set(ContentAutoUpdateJob.key, ContentAutoUpdateJob.queue)
return [handlers, queues]
}

View File

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

View File

@ -18,7 +18,14 @@ const loggerConfig = defineConfig({
targets:
targets()
.pushIf(!app.inProduction, targets.pretty())
// Production: write JSON to both the persisted log file (for Debug
// Info bundle export) AND stdout (so `docker logs nomad_admin` and
// any external log aggregator can see runtime telemetry — RAG
// retrieval scores, query rewrites, etc.). Writing to fd 1 via
// pino/file is the standard way to do this; without it, prod
// installs are effectively running blind from a debugger's POV.
.pushIf(app.inProduction, targets.file({ destination: "/app/storage/logs/admin.log", mkdir: true }))
.pushIf(app.inProduction, targets.file({ destination: 1 }))
.toArray(),
},
},

View File

@ -1,10 +1,42 @@
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: {
host: env.get('REDIS_HOST'),
port: env.get('REDIS_PORT') ?? 6379,
},
connection: sharedConnection,
}
export default queueConfig

View File

@ -8,6 +8,7 @@ export default defineConfig({
driver: redis({
host: env.get('REDIS_HOST'),
port: env.get('REDIS_PORT'),
db: env.get('REDIS_DB') ?? 0,
keyPrefix: 'transmit:',
})
}

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', 'ai.assistantCustomName', 'ai.remoteOllamaUrl', 'ai.ollamaFlashAttention', 'rag.defaultIngestPolicy'];
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'];

View File

@ -86,18 +86,27 @@ export const SYSTEM_PROMPTS = {
- Use tables when presenting structured data.
`,
rag_context: (context: string) => `
You have access to relevant information from the knowledge base. This context has been retrieved based on semantic similarity to the user's question.
Information has been retrieved from the NOMAD knowledge base that MAY be relevant to the
user's question. It was selected by automated similarity search, which is imperfect some
or all of it may be unrelated to what the user actually asked.
[Knowledge Base Context]
${context}
IMPORTANT INSTRUCTIONS:
1. If the user's question is directly related to the context above, use this information to provide accurate, detailed answers.
2. Always cite or reference the context when using it (e.g., "According to the information available..." or "Based on the knowledge base...").
3. If the context is only partially relevant, combine it with your general knowledge but be clear about what comes from the knowledge base.
4. If the context is not relevant to the user's question, you can respond using your general knowledge without forcing the context into your answer. Do not mention the context if it's not relevant.
5. Never fabricate information that isn't in the context or your training data.
6. If you're unsure or you don't have enough information to answer the user's question, acknowledge the limitations.
HOW TO ANSWER:
1. First, silently judge whether the context genuinely addresses the user's question. Use
it ONLY when it really contains relevant information. Do not force a connection that
isn't there: poetic, narrative, tangential, or topically-unrelated passages are NOT
relevant just because they share a word with the question ignore them.
2. When the context is relevant, base your answer on it and answer directly and specifically.
3. When the context does not actually address the question, ignore it completely and answer
from your own general knowledge. Do this silently do not mention the knowledge base,
the context, or the fact that it lacked an answer, and do not apologize.
4. Never narrate your retrieval or reasoning process. Do not write "according to Context 1",
"the context is unrelated, but", "I couldn't find specific context", or similar. Just
give the answer as if you simply knew it.
5. Do not fabricate specifics (numbers, names, procedures) that are neither supported by
genuinely relevant context nor part of your reliable knowledge.
Format your response using markdown for readability.
`,

View File

@ -5,4 +5,17 @@ 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',
CALIBREWEB: 'nomad_calibreweb',
IT_TOOLS: 'nomad_it_tools',
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

@ -0,0 +1,29 @@
import { SERVICE_NAMES } from './service_names.js'
// In-app docs page (admin/docs/supply-depot-apps.md) served at /docs/supply-depot-apps.
export const SUPPLY_DEPOT_DOC_PAGE = 'supply-depot-apps'
// Maps a Supply Depot service to its section anchor on that page. Only services listed here get a
// "Docs" item in the Manage dropdown, so the link never points at a section that doesn't exist yet.
// Each anchor MUST match the heading id set in the .md file (e.g. `## Vaultwarden {% #vaultwarden %}`).
// Add an entry here the moment that app's section is written.
export const SUPPLY_DEPOT_DOC_ANCHORS: Record<string, string> = {
[SERVICE_NAMES.STIRLING_PDF]: 'stirling-pdf',
[SERVICE_NAMES.FILEBROWSER]: 'file-browser',
[SERVICE_NAMES.CALIBREWEB]: 'calibre-web',
[SERVICE_NAMES.IT_TOOLS]: 'it-tools',
[SERVICE_NAMES.EXCALIDRAW]: 'excalidraw',
[SERVICE_NAMES.HOMEBOX]: 'homebox',
[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.
export function getSupplyDepotDocLink(serviceName: string): string | null {
const anchor = SUPPLY_DEPOT_DOC_ANCHORS[serviceName]
return anchor ? `/docs/${SUPPLY_DEPOT_DOC_PAGE}#${anchor}` : null
}

View File

@ -0,0 +1,34 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'services'
async up() {
this.schema.alterTable(this.tableName, (table) => {
table.boolean('is_custom').notNullable().defaultTo(false)
table.string('category').nullable()
})
// Backfill categories for existing curated services
this.defer(async (db) => {
const updates: Array<{ service_name: string; category: string }> = [
{ service_name: 'nomad_kiwix_server', category: 'education' },
{ service_name: 'nomad_kolibri', category: 'education' },
{ service_name: 'nomad_ollama', category: 'ai' },
{ service_name: 'nomad_cyberchef', category: 'utility' },
{ service_name: 'nomad_flatnotes', category: 'productivity' },
]
for (const { service_name, category } of updates) {
await db.from('services').where('service_name', service_name).update({ category })
}
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('is_custom')
table.dropColumn('category')
})
}
}

View File

@ -0,0 +1,20 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'services'
async up() {
this.schema.alterTable(this.tableName, (table) => {
// Set when a user edits a curated (non-custom) app. Tells the seeder to stop
// overwriting that service's container_config on subsequent runs, so the user's
// customizations (e.g. a changed port) survive reboots and upgrades.
table.boolean('is_user_modified').notNullable().defaultTo(false)
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('is_user_modified')
})
}
}

View File

@ -0,0 +1,28 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'services'
async up() {
this.schema.alterTable(this.tableName, (table) => {
// Per-app opt-in for automatic updates (gated additionally by the global
// `appAutoUpdate.enabled` master switch). Default off — auto-update is opt-in.
table.boolean('auto_update_enabled').notNullable().defaultTo(false)
// Cool-off anchor: when the currently-available update was first detected.
// Registry tags carry no publish date, so cool-off is measured from first-seen.
table.timestamp('available_update_first_seen_at').nullable()
// Per-app failure backoff so one flapping app self-disables without affecting others.
table.integer('auto_update_consecutive_failures').notNullable().defaultTo(0)
table.string('auto_update_disabled_reason', 255).nullable()
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('auto_update_enabled')
table.dropColumn('available_update_first_seen_at')
table.dropColumn('auto_update_consecutive_failures')
table.dropColumn('auto_update_disabled_reason')
})
}
}

View File

@ -0,0 +1,36 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'installed_resources'
async up() {
this.schema.alterTable(this.tableName, (table) => {
// The newest catalog version detected for this resource (a YYYY-MM date
// stamp), or null when the installed copy is already current. Written by
// every freshness check (manual or auto) via reconcileResourceUpdateState.
table.string('available_update_version').nullable()
// Size of the available update (bytes), captured from the catalog so the
// status UI and the per-window data-cap selection don't need to re-query
// the mirror on every poll.
table.bigInteger('available_update_size_bytes').nullable()
// Cool-off anchor: when the currently-available update was first detected.
// ZIM/map versions carry no publish date we can trust, so cool-off is
// measured from first-seen (mirrors the per-app auto-update fields).
table.timestamp('available_update_first_seen_at').nullable()
// Per-resource failure backoff so one flapping download self-disables
// without affecting the rest of the auto-update run.
table.integer('auto_update_consecutive_failures').notNullable().defaultTo(0)
table.string('auto_update_disabled_reason', 255).nullable()
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('available_update_version')
table.dropColumn('available_update_size_bytes')
table.dropColumn('available_update_first_seen_at')
table.dropColumn('auto_update_consecutive_failures')
table.dropColumn('auto_update_disabled_reason')
})
}
}

View File

@ -0,0 +1,22 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'services'
async up() {
this.schema.alterTable(this.tableName, (table) => {
// User-set override for an app's launch ("Open") link, used when the instance sits behind a
// reverse proxy or local DNS (e.g. https://jellyfin.myhomelab.net). When null, the default
// host + port link (derived from ui_location) is used. Stored separately from ui_location so
// the default is always recoverable, and deliberately NOT synced by the service seeder so a
// curated app's override survives reseeds/upgrades.
table.string('custom_url').nullable()
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('custom_url')
})
}
}

View File

@ -0,0 +1,43 @@
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

@ -5,16 +5,31 @@ import env from '#start/env'
import { SERVICE_NAMES } from '../../constants/service_names.js'
import { KIWIX_LIBRARY_CMD } from '../../constants/kiwix.js'
type ServiceSeedRecord = Omit<
ModelAttributes<Service>,
| 'created_at'
| 'updated_at'
| 'id'
| 'available_update_version'
| 'update_checked_at'
| 'metadata'
| 'is_user_modified'
| 'is_deprecated'
| 'custom_url'
| 'auto_update_enabled'
| 'available_update_first_seen_at'
| 'auto_update_consecutive_failures'
| 'auto_update_disabled_reason'
> & { metadata?: string | null }
export default class ServiceSeeder extends BaseSeeder {
// Use environment variable with fallback to production default
private static NOMAD_STORAGE_ABS_PATH = env.get(
'NOMAD_STORAGE_PATH',
'/opt/project-nomad/storage'
)
private static DEFAULT_SERVICES: Omit<
ModelAttributes<Service>,
'created_at' | 'updated_at' | 'metadata' | 'id' | 'available_update_version' | 'update_checked_at'
>[] = [
private static DEFAULT_SERVICES: ServiceSeedRecord[] = [
// ── Core / original services ──────────────────────────────────────────────
{
service_name: SERVICE_NAMES.KIWIX,
friendly_name: 'Information Library',
@ -38,13 +53,15 @@ export default class ServiceSeeder extends BaseSeeder {
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'education',
depends_on: null,
},
{
service_name: SERVICE_NAMES.QDRANT,
friendly_name: 'Qdrant Vector Database',
powered_by: null,
display_order: 100, // Dependency service, not shown directly
display_order: 100,
description: 'Vector database for storing and searching embeddings',
icon: 'IconRobot',
container_image: 'qdrant/qdrant:v1.16',
@ -57,15 +74,15 @@ export default class ServiceSeeder extends BaseSeeder {
PortBindings: { '6333/tcp': [{ HostPort: '6333' }], '6334/tcp': [{ HostPort: '6334' }] },
},
ExposedPorts: { '6333/tcp': {}, '6334/tcp': {} },
// Disable Qdrant's anonymous telemetry to telemetry.qdrant.io. NOMAD is offline-first
// and ships with zero telemetry by default — Qdrant's upstream default of enabled
// telemetry doesn't match that posture.
// Disable anonymous telemetry — NOMAD is offline-first
Env: ['QDRANT__TELEMETRY_DISABLED=true'],
}),
ui_location: '6333',
installed: false,
installation_status: 'idle',
is_dependency_service: true,
is_custom: false,
category: null,
depends_on: null,
},
{
@ -75,7 +92,7 @@ export default class ServiceSeeder extends BaseSeeder {
display_order: 3,
description: 'Local AI chat that runs entirely on your hardware - no internet required',
icon: 'IconWand',
container_image: 'ollama/ollama:0.18.1',
container_image: 'ollama/ollama:0.24.0',
source_repo: 'https://github.com/ollama/ollama',
container_command: 'serve',
container_config: JSON.stringify({
@ -90,6 +107,8 @@ export default class ServiceSeeder extends BaseSeeder {
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'ai',
depends_on: SERVICE_NAMES.QDRANT,
},
{
@ -99,7 +118,7 @@ export default class ServiceSeeder extends BaseSeeder {
display_order: 11,
description: 'Swiss Army knife for data encoding, encryption, and analysis',
icon: 'IconChefHat',
container_image: 'ghcr.io/gchq/cyberchef:10.22.1',
container_image: 'ghcr.io/gchq/cyberchef:10.24.0',
source_repo: 'https://github.com/gchq/CyberChef',
container_command: null,
container_config: JSON.stringify({
@ -113,6 +132,8 @@ export default class ServiceSeeder extends BaseSeeder {
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'utility',
depends_on: null,
},
{
@ -138,42 +159,417 @@ export default class ServiceSeeder extends BaseSeeder {
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'productivity',
depends_on: null,
},
{
service_name: SERVICE_NAMES.KOLIBRI,
friendly_name: 'Education Platform',
// "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)',
powered_by: 'Kolibri',
display_order: 2,
description: 'Interactive learning platform with video courses and exercises',
icon: 'IconSchool',
container_image: 'treehouses/kolibri:0.12.8',
container_image: 'learningequality/kolibri:0.19.4',
source_repo: 'https://github.com/learningequality/kolibri',
container_command: null,
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
PortBindings: { '8080/tcp': [{ HostPort: '8300' }] },
Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/kolibri:/root/.kolibri`],
// 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`],
},
ExposedPorts: { '8080/tcp': {} },
ExposedPorts: { '8080/tcp': {}, '8311/tcp': {} },
Env: ['KOLIBRI_ZIP_CONTENT_PORT=8311'],
}),
ui_location: '8300',
ui_location: '8310',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'education',
depends_on: null,
},
// ── Supply Depot — curated catalog (ports 84008499) ─────────────────────
{
service_name: SERVICE_NAMES.STIRLING_PDF,
friendly_name: 'Stirling PDF',
powered_by: 'Stirling-Tools',
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',
source_repo: 'https://github.com/Stirling-Tools/Stirling-PDF',
container_command: null,
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
PortBindings: { '8080/tcp': [{ HostPort: '8400' }] },
Binds: [
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/stirling-pdf/configs:/configs`,
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/stirling-pdf/logs:/logs`,
],
},
ExposedPorts: { '8080/tcp': {} },
// Stirling v2 ignores the old v1 `DOCKER_ENABLE_SECURITY` flag and ships with
// `security.enableLogin: true` in settings.yml, so it boots behind a login wall.
// For a single-user offline appliance we open it straight to the tools. Users who
// want a login can flip this to `true` via Manage > Edit (env overrides settings.yml).
Env: ['SECURITY_ENABLELOGIN=false', 'LANGS=en_GB'],
}),
ui_location: '8400',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'productivity',
depends_on: null,
},
{
service_name: SERVICE_NAMES.FILEBROWSER,
friendly_name: 'File Browser',
powered_by: 'FileBrowser',
display_order: 21,
description: 'Web-based file manager — browse, upload, download, and organize files on your device',
icon: 'IconFolderOpen',
container_image: 'filebrowser/filebrowser:v2',
source_repo: 'https://github.com/filebrowser/filebrowser',
// Browsable root is storage/filebrowser/files (persistent, so files created at the top level
// survive updates), with the user-facing content folders mounted in beneath it. We deliberately
// do NOT mount the sensitive/app-internal folders (vaultwarden, ollama, qdrant, logs, other
// apps' config + *.db). They simply aren't present in the container, so they can't be browsed,
// downloaded, or deleted — this is the guardrail. FileBrowser's own rules feature would need a
// wrapper script / imported config shipped into the container, which the catalog model doesn't
// support, so mount selection is how we scope visibility. To expose another content folder,
// add a `${STORAGE}/<folder>:/srv/<folder>` bind here.
// The DB lives in storage/filebrowser/db (mounted at /db, a SIBLING of the root, not under it)
// so FileBrowser's own .filebrowser.db never shows up in the user's file listing. User: root so
// it can read/write folders owned by other UIDs.
container_command: '--root /srv --database /db/.filebrowser.db',
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
PortBindings: { '80/tcp': [{ HostPort: '8410' }] },
Binds: [
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/filebrowser/files:/srv`,
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/filebrowser/db:/db`,
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/books:/srv/books`,
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/maps:/srv/maps`,
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/media:/srv/media`,
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/kb_uploads:/srv/kb_uploads`,
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/zim:/srv/zim`,
],
},
ExposedPorts: { '80/tcp': {} },
// Without an initial password FileBrowser generates a random one and prints it only to
// the container logs, which a non-technical user can't reach. Seed a known admin/nomad
// login on first run instead (only applies when the DB doesn't exist yet); the docs tell
// users to change it. FB_NOAUTH / --noauth don't work on this image (v2.63.x), so a login
// stays, which is the safer default anyway for a read/write/delete file manager.
// NOTE: FB_PASSWORD must be a bcrypt hash, not plaintext. The value below is the hash of
// "nomad" (generated via `filebrowser hash nomad`). Login is admin / nomad.
Env: [
'FB_USERNAME=admin',
'FB_PASSWORD=$2a$10$Dvu3XTiLxvPTzvdOKu6y6.AmadN6Zt0ddLwK.8MQ.RCIQWunWBQXa',
],
User: 'root'
}),
ui_location: '8410',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'utility',
depends_on: null,
},
{
service_name: SERVICE_NAMES.CALIBREWEB,
friendly_name: 'Calibre Web',
powered_by: 'Calibre-Web',
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',
source_repo: 'https://github.com/janeczku/calibre-web',
container_command: null,
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
PortBindings: { '8083/tcp': [{ HostPort: '8420' }] },
Binds: [
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/calibreweb/config:/config`,
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/books:/books`,
],
},
ExposedPorts: { '8083/tcp': {} },
Env: ['PUID=1000', 'PGID=1000'],
}),
ui_location: '8420',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'media',
depends_on: null,
metadata: JSON.stringify({ minMemoryMB: 512, minDiskMB: 5120 }),
},
{
service_name: SERVICE_NAMES.IT_TOOLS,
friendly_name: 'IT Tools',
powered_by: 'IT-Tools',
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',
source_repo: 'https://github.com/CorentinTh/it-tools',
container_command: null,
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
PortBindings: { '80/tcp': [{ HostPort: '8430' }] },
},
ExposedPorts: { '80/tcp': {} },
}),
ui_location: '8430',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'utility',
depends_on: null,
},
{
service_name: SERVICE_NAMES.EXCALIDRAW,
friendly_name: 'Excalidraw',
powered_by: 'Excalidraw',
display_order: 24,
description: 'Virtual whiteboard for sketching hand-drawn-style diagrams — works fully offline',
icon: 'IconPencil',
container_image: 'excalidraw/excalidraw:sha-4bfc5bb',
source_repo: 'https://github.com/excalidraw/excalidraw',
container_command: null,
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
PortBindings: { '80/tcp': [{ HostPort: '8440' }] },
},
ExposedPorts: { '80/tcp': {} },
}),
ui_location: '8440',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'productivity',
depends_on: null,
},
{
service_name: SERVICE_NAMES.MESHTASTIC_WEB,
friendly_name: 'Meshtastic Web',
powered_by: 'Meshtastic',
display_order: 30,
description: 'Browser-based client for managing Meshtastic mesh radio devices',
icon: 'IconWifi',
container_image: 'ghcr.io/meshtastic/web:2.7.1',
source_repo: 'https://github.com/meshtastic/web',
container_command: null,
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
// meshtastic/web serves on 8080 inside the container, not 80.
PortBindings: { '8080/tcp': [{ HostPort: '8450' }] },
},
ExposedPorts: { '8080/tcp': {} },
}),
ui_location: '8450',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'networking',
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',
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`,
],
},
ExposedPorts: { '443/tcp': {} },
}),
ui_location: 'https:8500',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'networking',
depends_on: null,
},
{
service_name: SERVICE_NAMES.HOMEBOX,
friendly_name: 'Homebox',
powered_by: 'Homebox',
display_order: 25,
description: 'Home inventory and asset management — track everything you own',
icon: 'IconBox',
// 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',
source_repo: 'https://github.com/sysadminsmedia/homebox',
container_command: null,
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
PortBindings: { '7745/tcp': [{ HostPort: '8470' }] },
Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/homebox:/data`],
},
ExposedPorts: { '7745/tcp': {} },
}),
ui_location: '8470',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'productivity',
depends_on: null,
},
{
service_name: SERVICE_NAMES.VAULTWARDEN,
friendly_name: 'Vaultwarden',
powered_by: 'Vaultwarden',
display_order: 26,
description: 'Lightweight Bitwarden-compatible password manager server — secure your credentials offline',
icon: 'IconShieldLock',
container_image: 'vaultwarden/server:1.36.0',
source_repo: 'https://github.com/dani-garcia/vaultwarden',
container_command: null,
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
PortBindings: { '80/tcp': [{ HostPort: '8480' }] },
Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/vaultwarden:/data`],
},
ExposedPorts: { '80/tcp': {} },
// ROCKET_TLS points at the self-signed cert generated on install by
// DockerService._runPreinstallActions__Vaultwarden. Vaultwarden's web vault needs a secure
// context (HTTPS) or it refuses to register/unlock, so it ships HTTPS-on-by-default.
Env: [
'WEBSOCKET_ENABLED=true',
'ROCKET_TLS={certs="/data/cert.pem",key="/data/key.pem"}',
],
}),
// https: prefix tells getServiceLink to build an https:// Open link on this port.
ui_location: 'https:8480',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'security',
depends_on: null,
metadata: JSON.stringify({ minMemoryMB: 256, minDiskMB: 512 }),
},
{
service_name: SERVICE_NAMES.JELLYFIN,
friendly_name: 'Jellyfin',
powered_by: 'Jellyfin',
display_order: 27,
description: 'Open-source media server — stream your video, music, and photo libraries',
icon: 'IconMovie',
container_image: 'jellyfin/jellyfin:10.11.11',
source_repo: 'https://github.com/jellyfin/jellyfin',
container_command: null,
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
PortBindings: { '8096/tcp': [{ HostPort: '8490' }] },
Binds: [
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/jellyfin/config:/config`,
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/jellyfin/cache:/cache`,
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/media:/media`,
],
},
ExposedPorts: { '8096/tcp': {} },
}),
ui_location: '8490',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'media',
depends_on: null,
metadata: JSON.stringify({ minMemoryMB: 2048, minDiskMB: 20480 }),
},
]
async run() {
const existingServices = await Service.query().select('service_name')
const existingServiceNames = new Set(existingServices.map((service) => service.service_name))
const existingServices = await Service.query().select([
'service_name',
'is_custom',
'is_user_modified',
])
const existingServiceMap = new Map(existingServices.map((s) => [s.service_name, s]))
const newServices = ServiceSeeder.DEFAULT_SERVICES.filter(
(service) => !existingServiceNames.has(service.service_name)
(service) => !existingServiceMap.has(service.service_name)
)
await Service.createMany([...newServices])
if (newServices.length > 0) {
await Service.createMany([...newServices])
}
// Keep curated services in sync with the catalog. Custom services are user-defined and must
// never be overwritten. User-modified curated services (a user edited their config) are
// likewise left alone so the edit survives reboots. ui_location is synced too so a catalog
// change to an app's link/scheme/port (e.g. Vaultwarden moving to https:8480, or a corrected
// internal port) reaches existing non-modified installs on update, not just fresh ones.
for (const service of ServiceSeeder.DEFAULT_SERVICES) {
const existing = existingServiceMap.get(service.service_name)
if (existing && !existing.is_custom && !existing.is_user_modified) {
await Service.query().where('service_name', service.service_name).update({
container_config: service.container_config,
container_command: service.container_command ?? null,
metadata: (service as any).metadata ?? null,
category: service.category,
ui_location: service.ui_location,
})
}
}
}
}

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 [Apps](/settings/apps) page.
The AI must be installed first — enable it during Easy Setup or install it from the [Supply Depot](/supply-depot) 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 Apps manager)
5. Try restarting the service (Stop, then Start in the Supply Depot)
### 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 **[Apps](/settings/apps)**
1. Go to **[Supply Depot](/supply-depot)**
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 [Apps](/settings/apps), find AI Assistant, and click **Force Reinstall**
3. **Reinstall the AI Assistant** — Go to [Supply Depot](/supply-depot), 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 **[Apps](/settings/apps)**
2. Go to **[Supply Depot](/supply-depot)**
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 **[Apps](/settings/apps)**
1. Go to **[Supply Depot](/supply-depot)**
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 → Apps**
1. Check that the AI Assistant is running in **Settings → Supply Depot**
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 → Apps**
1. Go to **Settings → Supply Depot**
2. Find the problematic service
3. Click **Restart**
4. Wait 30 seconds, then try again
@ -233,12 +233,17 @@ 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 [Apps](/settings/apps) page
1. Click **Information Library** from the Command Center home screen or the [Supply Depot](/supply-depot)
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 [Apps](/settings/apps) page
1. Click **Education Platform** from the Command Center home screen or the [Supply Depot](/supply-depot)
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 [Apps](/settings/apps) page.
**Note:** The AI Assistant must be installed first. Enable it during Easy Setup or install it from the [Supply Depot](/supply-depot).
**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.
**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.
---
@ -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 → Apps
- **More apps:** Settings → Supply Depot
- **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,6 +184,8 @@ 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 [Apps](/settings/apps) page.*
*Launch the Information Library from the home screen or the [Supply Depot](/supply-depot).*
### 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 [Apps](/settings/apps) page.*
*Launch the Education Platform from the home screen or the [Supply Depot](/supply-depot).*
### 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) |
| Download more content | [Install Apps →](/settings/apps) |
| Install more apps | [Supply Depot →](/supply-depot) |
| 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,4 +80,6 @@ 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,5 +1,134 @@
# Release Notes
## Version 1.33.0 - June 23, 2026
### 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!
- **Supply Depot — Curated App Onboarding & Fixes**: Each curated app now ships with NOMAD-specific getting-started docs (first run, default logins, where data lives, what does and doesn't work offline), deep-linked from a **Manage Docs** item on the card. Alongside it is a round of install fixes so the nine documented apps — Stirling PDF, File Browser, Calibre-Web, IT Tools, Excalidraw, Homebox, Vaultwarden, Jellyfin, Meshtastic Web — work out of the box: seeded logins instead of random passwords buried in logs, a bundled Calibre library, HTTPS-by-default where the app requires a secure context (Vaultwarden), corrected internal ports (Meshtastic Web), pre-created media folders (Jellyfin), and a swap to a maintained image (Homebox). You can now also **edit curated apps**, not just custom ones — edits are merged into the app's existing config (preserving advanced settings like GPU device requests) and flag the app so the seeder stops overwriting it, while untouched apps still receive catalog updates. Thanks @chriscrosstalk for the contribution!
- **Automatic Core Updates**: NOMAD's own admin/core image can now update itself hands-off, gated by layered safety checks. It's opt-in and off by default, runs only inside a user-configured time window, and applies only same-major, strictly-newer GA releases (major bumps stay manual) past a configurable cool-off — behind pre-flight checks for the update sidecar, no in-flight updates/downloads/installs, and sufficient host disk. It auto-disables after repeated genuine failures, while transient offline release lookups are treated as harmless skips. Settings → Updates exposes the toggle, window, cool-off, and live status. This is the first leg of the auto-update trilogy. Thanks @jakeaturner for the contribution!
- **Automatic App Updates**: Installed apps (the "Supply Depot" sibling containers) can now keep themselves up to date with opt-in, hands-off minor/patch updates, mirroring the core auto-update feature. Updates are gated behind a two-level opt-in — a global master switch in Settings → Updates **and** a per-app toggle in the Supply Depot — and respect the shared update window, cool-off period, disk/in-progress pre-flight checks, and per-app failure backoff. Major versions are never auto-applied. Thanks @jakeaturner for the contribution!
- **Automatic Content Updates**: Completing the auto-update trilogy, installed Kiwix ZIM files and PMTiles maps can now update themselves on an opt-in basis. Content updates run on their own dedicated overnight window and bandwidth cap (separate from the app/core schedule, since content downloads are multi-GB), check the upstream Kiwix and PMTiles catalogs directly (no more reliance on external Project N.O.M.A.D. API), and keep the AI Knowledge Base in sync when a ZIM is replaced. Thanks @jakeaturner for the contribution!
- **Supply Depot — Custom Launch URLs**: You can now override an app's "Open" link with a reverse-proxy or local-DNS address (e.g. `https://jellyfin.myhomelab.net`). The override is stored separately so the default link is always recoverable, survives reseeds/upgrades, and is validated on both client and server. Thanks @jakeaturner for the contribution!
- **Supply Depot — Version & Update visibility**: App cards now show the installed version next to the app name (e.g. `Kiwix · 3.7.0`), and the "Update available" pill now stands out with a solid desert-orange fill so available updates actually draw the eye. Thanks @chriscrosstalk for the contribution!
- **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!
- **System**: A failed service update now rolls back to the previous container if the new one fails to start, so the service stays up on the old version instead of being left down. Also clears any stale leftover container so a retry can't wedge indefinitely. Thanks @chriscrosstalk for the fix!
- **System**: Service install failures caused by a host port conflict (commonly a native Ollama install already on port 11434) now show a clear, actionable message with the exact commands to resolve it, instead of a raw Docker error. Thanks @chriscrosstalk for the fix!
- **System**: The per-service Update button is now disabled and shows "Updating..." while an update is in flight, preventing double-clicks that previously raced into Docker errors. The in-progress state is durable, so it survives a page reload during a multi-GB pull. Thanks @chriscrosstalk for the fix!
- **System Updates**: Update checks no longer crash for images with more than 1,000 tags (e.g Ollama). Registry pagination URLs are now resolved correctly, so the "Check for Updates" flow returns versions instead of failing silently — fixing Ollama appearing pinned at an old version. Thanks @chriscrosstalk for the fix!
- **System**: Internet status checks no longer report "No internet connection" on networks that block or hijack Cloudflare's 1.1.1.1. The check now probes additional hosts the app already contacts (GitHub and the Project N.O.M.A.D. API) in parallel and accepts any HTTP response as "online." Thanks @akashsalan for the fix!
- **AI Assistant**: Oversized embedding chunks are now truncated and retried instead of being silently dropped, ending the retry storm that could peg the GPU and flood logs (the "api/embed for weeks" issue). The OpenAI-compatible fallback path now also passes context and truncation settings. Thanks @chriscrosstalk for the fix!
- **AI Assistant**: Chat suggestions now use your selected model (falling back to the *smallest* installed model) instead of the largest. This prevents a flagship model that exceeds available VRAM from hanging the chat page and returning a 500 error. Thanks @johno10661 for the fix!
- **AI Assistant**: The assistant no longer disclaims "Sorry, I couldn't find specific context regarding X..." when relevant material was actually retrieved. The RAG prompt now treats retrieved context as the authoritative source and falls back to general knowledge silently, the model-visible relevance scores that primed smaller models to distrust correct context were replaced with neutral source-title labels, and a conservative heading-match boost improves the ranking of already-retrieved chunks. Thanks @jakeaturner for the fix!
- **Knowledge Base**: The embedded-chunk count for batched ZIM ingestion is now persisted accurately across continuation batches, instead of reporting only the final batch's count. Thanks @Metbcy for the fix!
- **Knowledge Base**: The "ingestion may have stalled" (partial-stall) warning no longer fires falsely on link-out- or PDF-heavy ZIMs that legitimately have little embeddable text. Thanks @chriscrosstalk for the fix!
- **Knowledge Base**: ZIM ingestion progress no longer freezes at 99% on multi-page archives (e.g. iFixit). Progress now creeps forward monotonically and only reports 100% on the genuinely final batch. Thanks @chriscrosstalk for the fix!
- **Content**: Installing a newer version of a curated map or ZIM now removes the superseded file from disk, preventing silent accumulation of orphaned content (potentially hundreds of GB). Deletion is gated behind strict safety rails — only tracked, genuinely-replaced, strictly-newer files within the content store are ever removed; sideloaded files are never touched. Thanks @chriscrosstalk for the fix!
- **Content**: Curated Wikipedia-themed ZIMs (e.g. `wikipedia_en_medicine_maxi` from Medicine → Comprehensive) are no longer wiped on restart. Reconciliation now skips only the single file actually managed by the Wikipedia selector, matched by exact filename. Thanks @chriscrosstalk for the fix!
- **Maps**: Having both an old and new copy of the same map region on disk no longer blanks the entire map. Map sources are now de-duplicated to the newest file per region, and already-broken installs recover automatically on the next page load. Thanks @chriscrosstalk for the fix!
- **Information Library (Kiwix)**: Kiwix now self-heals a missing or corrupt library file on startup by rebuilding it from the ZIM files on disk, instead of coming up with an empty library and no path to recovery. Thanks @chriscrosstalk for the fix!
- **Docker**: Failed image pulls (dropped/metered connection, bad manifest, disk full mid-pull) are now correctly treated as failures across all pull paths, instead of proceeding to create a container from a missing or partial image and surfacing a confusing downstream error. Thanks @chriscrosstalk for the fix!
- **Security**: Hardened the private-URL/SSRF guard by replacing the regex blocklist with proper IP-range classification (`ipaddr.js`) and normalizing the host first. This blocks alternate IP encodings and trailing-dot bypasses (e.g. `localhost.`) while no longer over-blocking legitimate public addresses. RFC1918 ranges and bare LAN hostnames remain allowed for local appliances. Thanks @chriscrosstalk for the fix!
- **Install**: Hardened the install script — the NVIDIA toolkit GPG step now runs non-interactively so it doesn't silently skip on non-TTY installs, and helper-script downloads now retry to reduce transient partial-install failures. Thanks @Gujiassh for the fix!
### Improvements
- **License & Docs**: Corrected the package license metadata to `Apache-2.0` (the project has been Apache-2.0 for some time), added a real project description, and fixed a dead Troubleshooting link plus several README typos. Thanks @aqilaziz for the contribution!
- **Docs**: Fixed a few typos and punctuation in the README. Thanks @teccdev for the contribution!
- **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
### Features
### Bug Fixes
- fix(logging): also write production logs to stdout for docker visibility (#870). Thanks @chriscrosstalk for the contribution!
- fix(KB): cursor on Always/Manual ingest policy buttons (#927). Thanks @chriscrosstalk for the contribution!
### Improvements
- perf(KB): swap Qdrant full-scroll for facet on source enumeration (#928). Thanks @chriscrosstalk for the contribution!
- chore(deps): bump various dependencies. Thanks @jakeaturner for the contribution!
## Version 1.32.0 - May 20, 2026
### Features
- **AI:** improved AMD GPU acceleration for Ollama via ROCm + HSA override (#804)
- **chat:** confirm-on-switch + one-chat-model-at-a-time enforcement (#ffa70a5)
- **content-manager:** add sortable file size column (#698), closes #685
- **content-updates:** show size, surface downloads in Active Downloads (#299b767)
- **Content:** custom ZIM library sources with pre-seeded mirrors (#593) (#62e75fd), closes #576
- **easy-setup:** split AI into its own conditional step (issue #905) (#0617d54), closes #907
- **GPU:** auto-remediate nomad_ollama passthrough loss on admin boot (#755) (#2997637), closes #208 #804
- **KB:** Always/Manual ingest policy toggle (RFC #883 §1/§4) (#894) (#8eb8809), closes #880 #886 #886 #886 #888 #888 #888 #888 #888
- **KB:** conditional warnings A + B on Stored Files (RFC #883 §6) (#563f86a), closes #891 #891 #890 #881
- **KB:** first-chat JIT prompt for ingest policy (RFC #883 Phase 3 task 12) (#fd153b4), closes #894 #894 #894
- **KB:** group admin docs into single row in Stored Files (RFC #883 §9) (#c64ec97)
- **KB:** guardrail modal at 50GB / 10%-free thresholds (RFC #883 §7) (#cf3a924), closes #897 #897 #894 #899
- **KB:** per-file ingest action + state indicator on Stored Files (RFC #883 §5) (#d850cb9), closes #907 #907 #907 #908
- **KB:** per-file ingest state machine (Phase 1 of RFC #883) (#888) (#743549c), closes #880 #886 #886 #886
- **KB:** ratio registry for disk + time estimates (Phase 1B of RFC #883) (#159d57b)
- **KB:** status pill + last-activity timestamp on Processing Queue (RFC #883 §5/§10) (#43ca584)
- **KB:** surface embedding-disk estimate in curated tier-change modal (RFC #883 §1) (#e68c753), closes #891 #891
- **KB:** wizard AI policy step (RFC #883 Phase 3 task 13) (#7a681d0), closes #899 #894 #894 #899
- **Maps:** regional map downloads via go-pmtiles extract (#780) (#94059b0)
- **maps:** show map coordinates on mouse move (#786) (#08838b1)
### Bug Fixes
- **AI:** add truncation DEBUG log (#e3b758f)
- **AI:** improve remote Ollama url validation to prevent SSRF vulnerability (#989a401)
- **AI:** pre-cap embed input + log fallback reason (#881) (#2dec5bf), closes #369 #670
- **AI:** preserve semver tag in DB on AMD Ollama updates (#019a5a4)
- **AI:** rewrite RAG query on first follow-up (off-by-one in skip-rewrite threshold) (#43645e4)
- **AI:** vendor-aware AMD HSA override + benchmark discrete-GPU detection (#a2e2f7f), closes #804 #804 #810
- **API:** accept notes, marker_type, and position on markers endpoints (#770) (#132ec9c), closes #768
- **API:** skip compression for Server-Sent Events (#798) (#4b21ea6)
- **content:** show selected tier on cards while downloads are in flight (#059cf2a), closes #36b6d8e
- **DockerService:** improve volume logic and documentation in forceReinstall (#501860a)
- **Downloads:** treat missing Content-Type as octet-stream (#848) (#3abf338)
- **install:** warn loudly on non-x86_64 architectures before pulling images (#797) (#cb129d2), closes #419
- **KB:** add re-embed and reset & rebuild opts to fix broken embeddings (#886) (#4c21196)
- **KB:** align chunks_per_mb column type with TS contract (#4d6b140)
- **KB:** blank-screen on panel open + tooltips on bulk-action buttons (#633a3c3), closes #892 #895 #post-#892
- **KB:** guardrail bypass during estimate load + Transition sibling (PR #901 review) (#7e768f3)
- **KB:** remove redundant Refresh button from Processing Queue (#4e8cadd), closes #893
- **KB:** respect Manual ingest policy on post-download dispatch (#a5fe52f), closes #909
- **KB:** silent maybe-later error + redundant prompt-state refetches (PR #899 review) (#9a684a5)
- **KB:** surface file-warning compute failures instead of masking as healthy (PR #895 review) (#a0047c1)
- **KB:** TierSelectionModal hook order + register IconLibrary (#6e5284e), closes #915
- **KB:** union Stored Files list with state-machine file paths (#898) (#8ed0bdf), closes #886 #888 #888
- **Maps:** render notes in marker popup when populated (#f41027c), closes #770
- **Maps:** send filename instead of full path to delete endpoint (#6a68bac)
- **models:** correct inverted belongsTo keys on ChatMessage.session (#921) (#82f67de)
- **queue:** singleton QueueService to stop ioredis connection leak (#ba53702), closes #872
- **RAG:** add start button in kb modal and ensure restart policy exists (#700) (#2d8a02f)
- **RAG:** anchor continuation-batch initial progress to overall-file frame (#889) (#f304d80)
- **RAG:** pace continuation batches when embedding is CPU-only (#a22c640)
- **RAG:** pass num_ctx and truncate to Ollama embed call (#763) (#7bebedc), closes #756 #369 #670
- **RAG:** report ZIM ingestion progress in overall-file frame (#d28eb9b)
- **RAG:** unbreak multi-batch ZIM ingestion (jobId dedupe) (#74cef75)
- **security:** canonicalize hostnames to block IPv4-mapped IPv6 IMDS bypass (#736c9bd)
- **security:** match IPv6 SSRF patterns against unbracketed hostnames (#b3dac9b)
- **System:** correct AMD VRAM in Graphics card + harden log probe (#d2f2172), closes #835 #850 #208
- **System:** correct NVIDIA VRAM in Graphics card (#835) (#6c799dd), closes #804
- **System:** self-heal stale updateAvailable flag after sidecar-driven update (#825) (#318276c)
- **System:** validate StartedAt with fallback to tail:500 (PR review) (#662a6c4)
- **UI:** Country Picker UX polish + auto-refresh stored files (#817) (#8c06b5b), closes #780
- **UI:** four fixes for the System Update page (#827) (#3a2e92a)
- **UI:** improve global map banner display logic (#702) (#5517e82)
- **UI:** wire map file delete confirmation to API (#732) (#e561ce8)
- **ZIM:** preserve co-existing Wikipedia corpora on cleanup (#884) (#5e2c599)
### Improvements
## Version 1.31.1 - April 21, 2026
### Features
@ -90,6 +219,34 @@
## Version 1.31.1 - April 21, 2026
### Features
- **AI Assistant**: Added improved support for AMD GPU acceleration for Ollama via ROCm + HSA override. Thanks @chriscrosstalk for the contribution!
- **Content Explorer**: Added support for custom ZIM library sources and pre-seeded ZIM library mirrors in addition to the default Kiwix library. Thanks @chriscrosstalk for the contribution!
- **Content Manager**: Content update sizes and downloads are now properly displayed in Active Downloads with progress bars and friendly names. Thanks @chriscrosstalk for the contribution!
- **Maps**: Map regions can now be extracted and downloaded locally from PMTiles to avoid the need for a full global map download for users who only want specific regions. Thanks @bgauger for the contribution!
### Bug Fixes
- **API**: Compression is now skipped for Server-Sent Events (SSE) responses to prevent issues with streaming endpoints. Thanks @chriscrosstalk for the fix!
- **Maps**: Fixed logic issues with the global map banner display. Thanks @Gujiassh for the fix!
- **Maps**: The selected map file is now properly deleted after confirming the action in the UI. Thanks @cuyua9 for the fix!
- **System**: Fixed an issue where the a pending update could still be indicated in the UI even after the system was updated successfully. Thanks @jakeaturner for the fix!
### Improvements
- **Build**: The Command Center image now uses the VERSION build arg to write `app/version.json` with the current version for improved version tracking and debugging, even in RC environments. Thanks @chriscrosstalk for the contribution!
- **Content Manager**: Added a sortable file size column to the ZIM files table in the Content Manager for easier management of storage space. Thanks @chriscrosstalk for the contribution!
- **Dependencies**: All package.json dependencies have been pinned to specific versions to ensure stability and reduce the risk of unexpected breaking changes/supply-chain compromises from upstream packages. Thanks @jakeaturner for the contribution!
- **Dependencies**: Updated various dependencies to close security vulnerabilities and improve stability
- **Docs**: Update CONTIRBUTING.md to require an issue to be opened before submitting a PR for non-trivial changes to ensure proper discussion and review of proposed changes. Thanks @chriscrosstalk for the contribution!
- **Docs**: Added the map markers endpoints to the API reference documentation. Thanks @kennethbrewer3 for the contribution!
- **Docs**: Added a link to the new WSL2 install guide in the README and FAQ. Thanks @chriscrosstalk for the contribution!
- **Install**: The install script now warns loudly if the user is attempting to install on a non-x86_64/amd64 platform to prevent unsupported installations and potential issues. Thanks @chriscrosstalk for the contribution!
- **Maps**: The maps API endpoints now properly accept and validate notes, marker_type, and position data for map markers and persist them in the database for retrieval in the UI. Thanks @jrsphoto for the contribution!
- **Maps**: The current coordinates of the mouse pointer can now be displayed in the map viewer for easier navigation and exploration. Thanks @kennethbrewer3 for the contribution!
- **RAG**: NOMAD now properly passed `num_ctx` and truncation to the Ollama embedding endpoint to ensure that the context window of the model is best utilized for embeddings. Thanks @chriscrosstalk for the contribution!
- **RAG**: Added a manual start button for Qdrant and a self-healing mechanism for Qdrant's restart-policy to ensure that the vector database is running properly for embedding and retrieval tasks. Thanks @hestela for the contribution!
## Version 1.31.1 - April 21, 2026
### Features
### Bug Fixes

View File

@ -0,0 +1,284 @@
# Supply Depot Apps
The Supply Depot is where you install extra apps onto your NOMAD beyond the built-in tools. Each app runs in its own container on your NOMAD, fully offline, and shows up with an **Open** button once it finishes installing.
This page covers what you need to know to get up and running with each app *on NOMAD specifically*: whether you log in, what the default credentials are, where your files end up, and anything you need to have on hand first. It does not cover how to use the apps themselves. Each app is its own open-source project with its own documentation, and we link out to that for every one.
A quick note on logins: some of these apps have their own accounts, separate from your NOMAD login. Where an app asks you to sign in, we tell you the starting credentials and whether you should change them.
---
## 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.
**Official site:** [stirlingpdf.com](https://stirlingpdf.com) · **Source:** [github.com/Stirling-Tools/Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF)
**First time you open it:** It opens straight to the tools, no login required. We set Stirling up to skip its login screen, since on a NOMAD it's a personal tool on your own network and a password wall just gets in the way. You'll see "Guest" in the bottom-left corner, which is normal.
**Heads up, it's a slow starter:** Stirling PDF is a big Java application. After you install it, give it 30 to 60 seconds to finish starting up before it loads cleanly. It also wants a good chunk of memory (around a gigabyte), so it's happier on a NOMAD with room to spare.
**Want a password on it?** If you'd rather Stirling require a login (say a few people share your NOMAD and you want this app locked down), you can turn its login back on from NOMAD:
1. On the Supply Depot page, find Stirling PDF and click **Manage > Edit**.
2. Under **Environment Variables**, change `SECURITY_ENABLELOGIN=false` to `SECURITY_ENABLELOGIN=true`.
3. Save. NOMAD rebuilds the app, and the login screen comes back.
The first time you sign in after that, use username `admin` and password `stirling`. Stirling will make you set your own password right away. Note that this is the only way to flip the login back on: Stirling's own settings menu is locked behind being logged in, so once login is off you turn it on from NOMAD's Edit screen, not from inside Stirling.
**Your data:** Your settings live in the `storage/stirling-pdf` folder on your NOMAD. The PDFs you work on are uploaded for the operation and downloaded back to your own device. Stirling isn't a long-term library, so it's not holding onto your documents.
**Where your PDFs come from (and why you don't see your NOMAD's files):** Stirling works on files from whatever device you're using, your laptop, phone, or tablet. You click "Open from computer," pick a PDF, work on it, then download the result back to that device. Stirling can't reach into files stored elsewhere on your NOMAD, so it won't show you your books folder, your Knowledge Base documents, or anything sitting in File Browser. If the PDF you want is already on your NOMAD, download it from wherever it lives first (File Browser, for example), then open that copy in Stirling. It's one extra step, but it's also why your files stay exactly where you put them instead of getting pulled into another app.
**Works offline:** All of the PDF tools run locally on your NOMAD, so the toolbox itself works fully offline. A few side features reach out to the internet and won't do anything when you're disconnected: the "Google Drive" import option, and the links in the footer (Survey, Discord, GitHub). None of those matter for actually working on PDFs. The one core feature with an online piece is OCR, which reads text out of scanned pages: it ships with English already installed, and adding other languages is the only part that would need a connection.
## File Browser {% #file-browser %}
A web-based file manager for your NOMAD. Browse folders, upload and download files, create folders, rename, move, and delete, all from your browser with nothing to install on your computer. It's a handy way to move files on and off the device or tidy things up without dropping into a command line.
**Official site:** [filebrowser.org](https://filebrowser.org) · **Source:** [github.com/filebrowser/filebrowser](https://github.com/filebrowser/filebrowser)
**First time you open it:** You'll get a login screen. Sign in with username `admin` and password `nomad`. **Change that password right away.** It's the same default on every NOMAD, so until you change it, anyone on your network who knows it could get in. Click the settings gear, open your profile settings, and set a new password.
Unlike most of the apps here, File Browser keeps its login on purpose. It can rename and delete real files on your NOMAD, so a password is the right call even on your own network.
**What you can see:** File Browser shows you your NOMAD's content folders in one place:
- **books** - e-books, including anything you want Calibre-Web to read
- **maps** - downloaded map data
- **media** - video, music, and photos, including anything you want Jellyfin to serve
- **zim** - downloaded offline content like Wikipedia and other reference libraries
- **kb_uploads** - documents you've added to the Knowledge Base
You can upload, download, rename, move, and delete inside these, and anything you drop in the top level is saved too. The behind-the-scenes folders that the apps actually run on (things like the AI models, the search index, and the password vault) are deliberately kept out of File Browser, so you can't browse or delete them by accident.
> **A word on deleting:** what you delete here is really gone, there's no recycle bin. The content is mostly replaceable (you can re-download a map or a Wikipedia library), but if you delete a book or a video you added yourself, that copy is gone. Delete with the same care you would on your own computer.
**Works offline:** Fully offline. File Browser runs entirely on your NOMAD and doesn't reach out to the internet for anything, so it works exactly the same connected or not.
## Calibre-Web {% #calibre-web %}
A web-based reader and library manager for your e-book collection. Read books right in your browser, organize them by author, series, and tags, and send them to a Kindle or other e-reader. It pairs with the books folder on your NOMAD, so your whole library lives on the device and goes wherever it goes.
**Official site:** [github.com/janeczku/calibre-web](https://github.com/janeczku/calibre-web)
**First time you open it:** Calibre-Web needs a library to point at, and NOMAD sets up an empty one for you during install, so you won't get stuck on a setup error. Here's the one-time flow:
1. Open Calibre-Web. It lands on a **Database Configuration** screen.
2. In the **Location of Calibre Database** box, type `/books` and click **Save**. You'll see "Database Settings updated" and your (empty) library opens.
3. That's it for setup. Your library is ready to fill.
If it asks you to sign in at any point, the default login is `admin` / `admin123`. **Change that password** once you're in (click `admin` in the top right, then Edit). It's the same default on every NOMAD.
**Adding books:** Uploading through the web page is turned off by default. To turn it on, go to **Admin** (top right) and edit the basic configuration to allow uploads, then you'll get an Upload button. You can also drop e-book files straight into the books folder using File Browser, then use Calibre-Web's "scan" to pick them up.
**Your data:** Your library lives in the `books` folder on your NOMAD (the same `books` you see in File Browser). Every book you add is stored there, so backing up that one folder backs up your whole collection.
**Works offline:** Reading and managing your library works fully offline. The one feature that reaches out to the internet is "fetch metadata," which pulls book covers and descriptions from online sources. That part won't do anything when you're offline, but it doesn't affect reading or organizing the books you already have.
## IT Tools {% #it-tools %}
A collection of over 100 small utilities you'd otherwise go hunting for online: hash generators, base64 and URL encoders, JSON and SQL formatters, UUID generators, a QR code maker, color converters, and a lot more. It all runs locally on your NOMAD, so you can use it with no internet connection.
**Official site:** [it-tools.tech](https://it-tools.tech) · **Source:** [github.com/CorentinTh/it-tools](https://github.com/CorentinTh/it-tools)
**First time you open it:** It opens straight to the tools. No login, no account, no setup. Pick a tool from the sidebar and use it.
**Your data:** There's nothing to manage. IT Tools doesn't store anything on your NOMAD between sessions, so there are no files, no library to set up, and no credentials to keep track of. It's the simplest app in the Supply Depot.
**Works offline:** Every tool runs right in your browser against the copy on your NOMAD. Nothing here reaches out to the internet, so all of it keeps working when you're offline.
## Excalidraw {% #excalidraw %}
A virtual whiteboard for quick, hand-drawn-style diagrams and sketches. Draw boxes, arrows, and freehand shapes, drop in text and images, and lay out a flowchart, a network diagram, or a rough idea in seconds. The whole thing has a friendly, sketched-on-a-napkin look, and it runs right in your browser.
**Official site:** [excalidraw.com](https://excalidraw.com) · **Source:** [github.com/excalidraw/excalidraw](https://github.com/excalidraw/excalidraw)
**First time you open it:** It opens straight to a blank canvas. No login, no account, no setup. Pick a shape from the toolbar and start drawing. You'll see a short welcome note reminding you that your work is saved in your browser, which leads to the one thing worth understanding about Excalidraw on NOMAD.
**Where your drawings live (read this part):** This version of Excalidraw has no storage on your NOMAD. Your drawing is saved inside the web browser you're using, on that one device. A few things follow from that:
- Your drawing is **not shared between devices**. What you draw on your laptop won't show up when you open Excalidraw on your phone, because each browser keeps its own copy.
- If you **clear your browser data**, or use a private/incognito window, the drawing is gone. There's no copy on the NOMAD to fall back on.
- So **save your work to a file.** Use the menu (top-left) to **Save to...** an `.excalidraw` file, and put it somewhere safe, for example your media or documents folder via File Browser. To pick it back up later, use **Open** and load that file. This is the only way to keep a drawing for the long term or move it to another device.
**Your data:** Because everything stays in your browser, there are no NOMAD folders or credentials to manage for Excalidraw. The files you save are wherever you choose to put them.
**Works offline:** The whiteboard itself works offline, you can draw, edit, and save files with no internet. Three things to know:
- **The signature hand-drawn font comes from the internet.** When your NOMAD is offline, Excalidraw can't fetch it and falls back to a plain font, so your diagrams look a little less sketchy. Your drawings themselves are completely unaffected, only the on-screen font changes.
- **Excalidraw sends anonymous usage analytics when your NOMAD is online.** The app's makers include basic page-view tracking (through a service called Simple Analytics) that records that the app was opened. It doesn't see your drawings, and it can't reach anything when your NOMAD is offline, but we want you to know it's there since NOMAD is otherwise built to keep to itself.
- **A few buttons are cloud features that don't work on NOMAD.** "Live collaboration," "Sign up," and "Excalidraw+" all point to the makers' paid online service and need the internet. They're not part of your offline whiteboard, so you can ignore them. The same goes for the shape **Library** browser, which pulls from an online gallery.
## Homebox {% #homebox %}
A home inventory system for keeping track of everything you own. Catalog your belongings into locations and labels, attach photos, record serial numbers, purchase prices, warranty dates, and receipts, and find anything with a search. It's a genuinely useful tool for insurance records, warranty tracking, and knowing what you have and where it is.
**Official site:** [homebox.software](https://homebox.software) · **Source:** [github.com/sysadminsmedia/homebox](https://github.com/sysadminsmedia/homebox)
**First time you open it:** Homebox lands on a login screen, but you don't have an account yet, so you create one. Click **Register**, then fill in:
- **your email** (used as your username to log in),
- **your name**,
- **a password** (Homebox shows a strength meter and won't let you register until the password is strong enough, so use a real one).
Click **Register**, then log in with that email and password. The first account you create is the **owner** of this Homebox. There are no default credentials to change, the account is yours from the start.
**Sharing your NOMAD with others?** By default Homebox lets anyone who can reach it create their own account. That's fine if it's just you, or if you trust everyone on your network. If you'd rather lock it down so no one else can register after you've made your account:
1. Create your owner account first (above).
2. On the Supply Depot page, find Homebox and click **Manage > Edit**.
3. Under **Environment Variables**, add `HBOX_OPTIONS_ALLOW_REGISTRATION=false`.
4. Save. NOMAD rebuilds the app, and the Register button stops creating new accounts. You can still log in normally.
**Your data:** Everything Homebox stores lives in one folder on your NOMAD, `storage/homebox`, as a single database file (plus any photos and receipts you attach). Backing up that one folder backs up your entire inventory.
**Works offline:** Fully offline. Homebox runs entirely on your NOMAD, keeps all your data locally, and has no usage tracking, so it works exactly the same whether your NOMAD is connected or not. The links in its header (GitHub, Discord, the project website) need the internet, but they're just shortcuts to the project's pages and have nothing to do with your inventory.
## Vaultwarden {% #vaultwarden %}
A private password manager that runs on your own NOMAD. It's compatible with Bitwarden, so you can store logins, secure notes, and card details in an encrypted vault, and use the official Bitwarden browser extensions and phone apps to access it, all pointed at your NOMAD instead of someone else's cloud.
**Official site:** [bitwarden.com](https://bitwarden.com) (for the apps and extensions) · **Source:** [github.com/dani-garcia/vaultwarden](https://github.com/dani-garcia/vaultwarden)
**First time you open it, you'll see a security warning. That's expected, here's why:** A password manager will only run over a secure (HTTPS) connection, so NOMAD sets Vaultwarden up with HTTPS automatically. Because your NOMAD is your own private device and not a public website, it uses a self-signed security certificate, and browsers show a warning the first time they see one. It looks alarming but it's normal for a device on your own network. To get past it once:
1. Click **Open** on the Vaultwarden 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 on the Vaultwarden vault. Your browser remembers your choice, so you won't see the warning again on that device.
**Creating your vault:** On the login page, click **Create account**, then set your **email** and a **master password**.
> **Your master password cannot be recovered.** Vaultwarden has no "forgot password" email and no reset, by design, because it never sees your password. If you forget it, the vault and everything in it is locked for good. Choose something strong that you won't lose, and consider writing it down somewhere physically safe.
**Sharing your NOMAD with others?** By default anyone who can reach Vaultwarden can create their own account (each account is separate and encrypted). If you'd rather no one else can register after you've set yours up:
1. Create your own account first.
2. On the Supply Depot page, find Vaultwarden and click **Manage > Edit**.
3. Under **Environment Variables**, add `SIGNUPS_ALLOWED=false`.
4. Save. NOMAD rebuilds the app and new sign-ups are turned off; existing accounts keep working.
**Using it from your phone and browser:** Install the official Bitwarden app or browser extension, and on its login screen choose **self-hosted** (or "Server URL") and enter `https://(your NOMAD's address):8480`. Note that some phone apps are stricter about self-signed certificates and may refuse to connect; the web vault you open from NOMAD always works.
**Your data:** Your encrypted vault lives in the `storage/vaultwarden` folder on your NOMAD. Backing up that folder backs up everything. (The built-in admin panel is turned off unless you set an admin token, which most people don't need.)
**Works offline:** Fully offline and private. Vaultwarden runs entirely on your NOMAD, stores your vault locally, and phones home to nobody. The Bitwarden apps and extensions also keep a local copy of your vault, so they can read your passwords even when your NOMAD or your phone is offline.
## Jellyfin {% #jellyfin %}
Your own media server. Point Jellyfin at a folder of movies, TV shows, music, and photos on your NOMAD, and it organizes everything with artwork and details and streams it to a web browser, phone, tablet, smart TV, or the Jellyfin apps. It's a private, offline alternative to the big streaming services for media you already own.
**Official site:** [jellyfin.org](https://jellyfin.org) · **Source:** [github.com/jellyfin/jellyfin](https://github.com/jellyfin/jellyfin)
**First time you open it, you'll go through a setup wizard.** It's a few quick screens:
1. **Language** - pick your display language and click Next.
2. **Create your admin account** - enter a username and password. This is the main account that controls the server, so give it a real password and keep track of it. (You can add more users, including limited ones for kids, later from the Dashboard.)
3. **Add your media** - click **Add Media Library** and pick a content type. To make this easy, NOMAD has already created a matching folder for each type inside your media folder, so you just point each library at the one that fits:
- **Movies** library → the `Movies` folder
- **Shows** library → the `TV Shows` folder
- **Music** library → the `Music` folder
- **Photos** library → the `Photos` folder
**Point each library at its own folder, not at the whole `media` folder.** This matters: if you point one library at `media` itself (which contains all the others) and another library at, say, `Music` inside it, Jellyfin sees the same files claimed twice, calls it a "duplicate path," and your music silently won't show up. One folder per library keeps everything tidy and working. You can also skip this step and add libraries later from the Dashboard.
4. **Metadata, remote access, finish** - accept the defaults on the remaining screens and finish. Then sign in with the account you just made.
**Getting your media in:** Put your files in the matching subfolder of the **media** folder on your NOMAD (the same `media` folder you see in File Browser): movies in **Movies**, series in **TV Shows**, music in **Music** (a folder per album works great), pictures in **Photos**. The easiest workflow is to upload files with File Browser (or drop them in however you like), then in Jellyfin click **Scan Library** to pick them up. Jellyfin reads sub-folders, so a whole album folder dropped into **Music** comes in as one album. It also works best when files are named clearly (for example `Movie Name (2020).mp4`), which helps it match the right artwork and details.
**Your data:** Your media lives in `storage/media`. Jellyfin's own settings, user accounts, and the artwork it downloads live in `storage/jellyfin`. Your media files are never modified, Jellyfin only reads them.
**Works offline:** Streaming your own media works fully offline, that's the whole point. The one piece that uses the internet is **fetching metadata**: when Jellyfin adds a movie or show, it tries to download a cover image, description, and cast info from online databases. Offline, it can't do that, so items show up with plain names and no artwork, but they still play perfectly. Once you're back online, a library scan fills in the missing artwork.
> **A note on playback performance:** Jellyfin plays most files effortlessly, but if a video's format isn't supported by your device, Jellyfin has to convert it on the fly ("transcoding"), which is heavy work for the processor. NOMAD doesn't set up graphics-card acceleration for this by default, so very large or high-resolution videos may stutter on a modest NOMAD. Playing files in a widely-supported format (like MP4/H.264) avoids transcoding and plays smoothest.
## Meshtastic Web {% #meshtastic-web %}
A browser-based control panel for [Meshtastic](https://meshtastic.org) devices. Meshtastic is off-grid, long-range radio messaging: small, inexpensive LoRa radios that form their own mesh network and send text messages and GPS locations for miles with no cell service, no internet, and no fees. This app is how you configure those radios and read and send messages from a full-size screen.
**Official site:** [meshtastic.org](https://meshtastic.org) · **Source:** [github.com/meshtastic/web](https://github.com/meshtastic/web)
**You need a Meshtastic radio to use this.** This app is just the control panel. On its own it opens to a "No devices connected" screen, because the actual work happens on a physical Meshtastic device (and the network of other radios it talks to). If you don't have one yet, the app won't do much.
**First time you open it:** It opens straight in, no login. Click **New Connection** and you'll see three ways to connect to your radio:
- **HTTP** - connect to a radio that's already joined to your Wi-Fi, by typing its IP address. **This is the method to use on NOMAD** (see below).
- **Bluetooth** - pair with a nearby radio over Bluetooth.
- **Serial** - connect to a radio plugged into a USB port.
**The NOMAD-specific catch (Bluetooth and Serial need HTTPS):** Browsers only allow a website to use Bluetooth or USB when the page is loaded over a secure (HTTPS) connection. NOMAD serves Meshtastic Web over plain HTTP, so on NOMAD the **Bluetooth and Serial options won't connect**, your browser blocks them. The one that works is **HTTP**: put your Meshtastic radio on the same Wi-Fi network (Meshtastic radios can join Wi-Fi), then connect to it here by its IP address. If you specifically need to pair over USB or Bluetooth, do that from the official Meshtastic phone app or the Meshtastic website instead.
**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.

67
admin/docs/updates.md Normal file
View File

@ -0,0 +1,67 @@
# 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

@ -0,0 +1,113 @@
import { useEffect, useState } from 'react'
import StyledModal from './StyledModal'
import StyledButton from './StyledButton'
import Input from './inputs/Input'
import { getServiceLink, normalizeCustomUrl } from '~/lib/navigation'
import { ServiceSlim } from '../../types/services'
import api from '~/lib/api'
interface AppUrlModalProps {
open: boolean
/** The app whose launch URL is being configured. Null while closed. */
service: ServiceSlim | null
onClose: () => void
/** Called after a successful save/clear so the parent can refresh the link. */
onSaved: () => void
showError: (msg: string) => void
}
/**
* Set or clear an app's custom launch URL used when the instance sits behind a reverse proxy or
* local DNS (e.g. https://jellyfin.myhomelab.net). Leaving the field empty clears the override and
* reverts to the default host + port link. Works for both curated and custom apps.
*/
export default function AppUrlModal({ open, service, onClose, onSaved, showError }: AppUrlModalProps) {
const [value, setValue] = useState('')
const [submitting, setSubmitting] = useState(false)
// Prefill from the app's stored override each time the modal opens for a service.
useEffect(() => {
if (open && service) setValue(service.custom_url ?? '')
}, [open, service])
const trimmed = value.trim()
const normalized = normalizeCustomUrl(value)
const isInvalid = trimmed.length > 0 && !normalized
// What clicking "Open" will actually resolve to once saved.
const previewLink = service ? getServiceLink(service.ui_location || '', value) : ''
const usingDefault = !normalized
async function handleSave() {
if (!service || isInvalid) return
setSubmitting(true)
// Empty clears the override; the backend re-normalizes/validates the value too.
const result = await api.setServiceCustomUrl(service.service_name, trimmed ? trimmed : null)
setSubmitting(false)
if (!result?.success) {
showError('Failed to save custom URL.')
return
}
onSaved()
}
const appName = service?.friendly_name || service?.service_name || 'this app'
return (
<StyledModal
title="Set Custom URL"
open={open}
onCancel={onClose}
onClose={onClose}
cancelText="Cancel"
onConfirm={handleSave}
confirmVariant="primary"
confirmText="Save"
confirmIcon="IconCheck"
confirmLoading={submitting}
confirmDisabled={isInvalid}
>
<div className="space-y-4 text-sm">
<p className="text-text-muted">
Set where <span className="font-medium text-text-primary">{appName}</span> opens from useful
if you reach it through a reverse proxy or local DNS. Leave this empty to use the default
address ({service?.ui_location ? `host + port ${service.ui_location}` : 'host + port'}).
</p>
<div>
<div className="flex items-end gap-2">
<Input
name="customUrl"
label="Custom URL"
placeholder="http://jellyfin.myhomelab.net"
value={value}
onChange={(e) => setValue(e.target.value)}
error={isInvalid}
className="flex-1 min-w-0"
/>
{value.length > 0 && (
<StyledButton size="sm" variant="ghost" icon="IconX" onClick={() => setValue('')} className="mb-1.5">
Clear
</StyledButton>
)}
</div>
{isInvalid ? (
<p className="mt-1.5 text-xs text-red-500">
Enter a valid http(s) address (e.g. https://jellyfin.myhomelab.net). A bare host like
"jellyfin.lan" becomes http://jellyfin.lan.
</p>
) : (
<>
<p className="mt-1.5 text-xs text-text-muted">
No scheme? We'll default to <span className="font-mono">http://</span>.</p>
<p className="mt-1.5 text-xs text-text-muted">
Opens as:{' '}
<span className="font-mono break-all text-text-primary">{previewLink}</span>
{usingDefault ? ' (default)' : ''}
</p>
</>
)}
</div>
</div>
</StyledModal>
)
}

View File

@ -0,0 +1,540 @@
import { useEffect, useState } from 'react'
import StyledModal from './StyledModal'
import StyledButton from './StyledButton'
import Alert from './Alert'
import api from '~/lib/api'
import Input from './inputs/Input'
import Select from './inputs/Select'
import DynamicIcon, { DynamicIconName } from './DynamicIcon'
import { IconTrash } from '@tabler/icons-react'
interface PortMapping {
container: string
host: string
}
interface VolumeMapping {
host_path: string
container_path: string
}
interface EnvVar {
value: string
}
export interface CustomAppInitial {
service_name: string
friendly_name: string | null
image: string
category: string
icon: string
ports: Array<{ container: number; host: number }>
volumes: Array<{ host_path: string; container_path: string }>
env: string[]
memory_mb?: number
cpus?: number
}
interface CustomAppModalProps {
open: boolean
onClose: () => void
onCreated: (serviceName: string) => void
showError: (msg: string) => void
/** 'edit' reconfigures an existing custom app (prefilled from `initial`); defaults to 'create'. */
mode?: 'create' | 'edit'
initial?: CustomAppInitial | null
}
const CATEGORY_OPTIONS = [
{ value: 'custom', label: 'Custom' },
{ value: 'productivity', label: 'Productivity' },
{ value: 'media', label: 'Media' },
{ value: 'security', label: 'Security' },
{ value: 'networking', label: 'Networking' },
{ value: 'utility', label: 'Utility' },
{ value: 'ai', label: 'AI' },
{ value: 'education', label: 'Education' },
]
// Curated subset of the DynamicIcon map suitable for custom apps.
const ICON_OPTIONS = [
{ value: 'IconBrandDocker', label: 'Docker (default)' },
{ value: 'IconBox', label: 'Box' },
{ value: 'IconServer', label: 'Server' },
{ value: 'IconDatabase', label: 'Database' },
{ value: 'IconCode', label: 'Code' },
{ value: 'IconTool', label: 'Tool' },
{ value: 'IconWorld', label: 'Web' },
{ value: 'IconShieldLock', label: 'Security' },
{ value: 'IconMovie', label: 'Media' },
{ value: 'IconBook', label: 'Book' },
{ value: 'IconNotes', label: 'Notes' },
{ value: 'IconCpu', label: 'Compute' },
{ value: 'IconRobot', label: 'AI / Bot' },
{ value: 'IconWifi', label: 'Network' },
{ value: 'IconHome', label: 'Home' },
]
export default function CustomAppModal({
open,
onClose,
onCreated,
showError,
mode = 'create',
initial = null,
}: CustomAppModalProps) {
const isEdit = mode === 'edit'
const [friendlyName, setFriendlyName] = useState('')
const [image, setImage] = useState('')
const [category, setCategory] = useState('custom')
const [icon, setIcon] = useState('IconBrandDocker')
const [ports, setPorts] = useState<PortMapping[]>([{ container: '', host: '' }])
const [volumes, setVolumes] = useState<VolumeMapping[]>([])
const [envVars, setEnvVars] = useState<EnvVar[]>([])
const [memoryMb, setMemoryMb] = useState('')
const [cpus, setCpus] = useState('')
const [submitting, setSubmitting] = useState(false)
const [portConflicts, setPortConflicts] = useState<Array<{ port: number; usedBy: string }>>([])
const [resourceWarnings, setResourceWarnings] = useState<string[]>([])
const [blocked, setBlocked] = useState<string[]>([])
const [forceInstall, setForceInstall] = useState(false)
const [checkingPreflight, setCheckingPreflight] = useState(false)
const [suggestedPort, setSuggestedPort] = useState<number | null>(null)
// On open: prefill from the existing app (edit) or fetch a suggested port (create).
useEffect(() => {
if (!open) return
if (isEdit && initial) {
setFriendlyName(initial.friendly_name ?? '')
setImage(initial.image)
setCategory(initial.category)
setIcon(initial.icon || 'IconBrandDocker')
setPorts(
initial.ports.length
? initial.ports.map((p) => ({ container: String(p.container), host: String(p.host) }))
: [{ container: '', host: '' }]
)
setVolumes(initial.volumes)
setEnvVars(initial.env.map((v) => ({ value: v })))
setMemoryMb(initial.memory_mb != null ? String(initial.memory_mb) : '')
setCpus(initial.cpus != null ? String(initial.cpus) : '')
return
}
api.suggestCustomPort().then((res) => {
if (res?.port) {
setSuggestedPort(res.port)
setPorts([{ container: '', host: String(res.port) }])
}
})
}, [open, isEdit, initial])
// Live preflight: whenever ports, volumes or the image change, debounce a check for port
// conflicts, resource/guard warnings and hard blocks so the user gets feedback before submitting.
useEffect(() => {
if (!open) return
const validPorts = ports
.map((p) => parseInt(p.host, 10))
.filter((p) => !isNaN(p))
const validVolumes = volumes.filter((v) => v.host_path && v.container_path)
if (validPorts.length === 0 && validVolumes.length === 0 && !image.trim()) {
setPortConflicts([])
setResourceWarnings([])
setBlocked([])
setCheckingPreflight(false)
return
}
setCheckingPreflight(true)
const handle = setTimeout(async () => {
const res = await api.preflightCustomApp({
image: image.trim() || undefined,
ports: validPorts.length ? validPorts : undefined,
volumes: validVolumes.length ? validVolumes : undefined,
exclude_service: isEdit && initial ? initial.service_name : undefined,
})
if (res) {
setPortConflicts(res.portConflicts ?? [])
setResourceWarnings(res.resourceWarnings ?? [])
setBlocked(res.blocked ?? [])
}
setCheckingPreflight(false)
}, 400)
return () => clearTimeout(handle)
}, [open, ports, volumes, image])
function resetForm() {
setFriendlyName('')
setImage('')
setCategory('custom')
setIcon('IconBrandDocker')
setPorts([{ container: '', host: '' }])
setVolumes([])
setEnvVars([])
setMemoryMb('')
setCpus('')
setPortConflicts([])
setResourceWarnings([])
setBlocked([])
setForceInstall(false)
setSuggestedPort(null)
}
function handleClose() {
resetForm()
onClose()
}
// ── Port row helpers ──────────────────────────────────────────────────────
function updatePort(idx: number, field: keyof PortMapping, value: string) {
setPorts((prev) => prev.map((p, i) => (i === idx ? { ...p, [field]: value } : p)))
}
function addPort() {
const nextHost = suggestedPort ? suggestedPort + ports.length * 10 : 8600 + ports.length * 10
setPorts((prev) => [...prev, { container: '', host: String(nextHost) }])
}
function removePort(idx: number) {
setPorts((prev) => prev.filter((_, i) => i !== idx))
}
// ── Volume row helpers ────────────────────────────────────────────────────
function updateVolume(idx: number, field: keyof VolumeMapping, value: string) {
setVolumes((prev) => prev.map((v, i) => (i === idx ? { ...v, [field]: value } : v)))
}
function addVolume() {
setVolumes((prev) => [...prev, { host_path: '', container_path: '' }])
}
function removeVolume(idx: number) {
setVolumes((prev) => prev.filter((_, i) => i !== idx))
}
// ── Env var helpers ───────────────────────────────────────────────────────
function updateEnv(idx: number, value: string) {
setEnvVars((prev) => prev.map((e, i) => (i === idx ? { value } : e)))
}
function addEnv() {
setEnvVars((prev) => [...prev, { value: '' }])
}
function removeEnv(idx: number) {
setEnvVars((prev) => prev.filter((_, i) => i !== idx))
}
// ── Submit ────────────────────────────────────────────────────────────────
async function handleSubmit() {
if (!friendlyName.trim() || !image.trim()) {
showError('Name and image are required.')
return
}
if (blocked.length > 0) {
showError('Resolve the blocked issues before installing.')
return
}
const validPorts = ports
.filter((p) => p.container && p.host)
.map((p) => ({ container: parseInt(p.container, 10), host: parseInt(p.host, 10) }))
.filter((p) => !isNaN(p.container) && !isNaN(p.host))
const validVolumes = volumes.filter((v) => v.host_path && v.container_path)
const validEnv = envVars.map((e) => e.value).filter(Boolean)
const parsedMemory = parseInt(memoryMb, 10)
const parsedCpus = parseFloat(cpus)
setSubmitting(true)
try {
const common = {
friendly_name: friendlyName.trim(),
image: image.trim(),
ports: validPorts.length ? validPorts : undefined,
volumes: validVolumes.length ? validVolumes : undefined,
env: validEnv.length ? validEnv : undefined,
category,
icon,
memory_mb: !isNaN(parsedMemory) ? parsedMemory : undefined,
cpus: !isNaN(parsedCpus) ? parsedCpus : undefined,
// The user has already acknowledged any conflicts via the "install anyway" checkbox.
force: forceInstall,
}
const result =
isEdit && initial
? await api.updateCustomApp({ service_name: initial.service_name, ...common })
: await api.createCustomApp(common)
if (result?.success && result.service_name) {
resetForm()
onCreated(result.service_name)
} else {
// Check if it's a port conflict error — show warnings and let user force
if (result?.message?.toLowerCase().includes('port') || result?.message?.toLowerCase().includes('conflict')) {
showError(result.message)
} else {
showError(result?.message || 'Failed to create custom app.')
}
}
} catch (err: any) {
showError(err?.message || 'Unexpected error creating custom app.')
} finally {
setSubmitting(false)
}
}
const hasWarnings = portConflicts.length > 0 || resourceWarnings.length > 0
const hasBlocks = blocked.length > 0
const canSubmit =
friendlyName.trim() && image.trim() && !hasBlocks && (!hasWarnings || forceInstall)
return (
<StyledModal
title={isEdit ? 'Edit App' : 'Add Custom App'}
open={open}
onCancel={handleClose}
cancelText="Cancel"
onConfirm={handleSubmit}
confirmVariant='primary'
confirmText={isEdit ? 'Save & Recreate' : 'Install'}
confirmIcon="IconBrandDocker"
confirmLoading={submitting}
confirmDisabled={!canSubmit}
large
>
<div className="space-y-6 text-sm">
{/* Image + Name */}
<div className="grid grid-cols-2 gap-4">
<Input
name='image'
label="Docker Image"
placeholder="e.g. nginx:latest"
value={image}
onChange={(e) => setImage(e.target.value)}
required
/>
<Input
name='friendlyName'
label="Display Name"
placeholder="My App"
value={friendlyName}
onChange={(e) => setFriendlyName(e.target.value)}
required
/>
</div>
{/* Category + Icon */}
<div className="grid grid-cols-2 gap-4 items-start">
<Select
name='category'
label='Category'
helpText='Select the most relevant category for this app. This helps with visual organization and filtering.'
value={category}
onChange={(newVal) => setCategory(newVal)}
options={CATEGORY_OPTIONS}
/>
<div className="flex items-end gap-2">
<Select
name='icon'
label='Icon'
helpText='Pick an icon shown on the app card.'
value={icon}
onChange={(newVal) => setIcon(newVal)}
options={ICON_OPTIONS}
className="flex-1 min-w-0"
/>
<div
className="flex-shrink-0 flex items-center justify-center h-[42px] w-[42px] rounded-md border border-border-default bg-surface-secondary"
title="Icon preview"
>
<DynamicIcon icon={icon as DynamicIconName} className="h-6 w-6 text-desert-green" />
</div>
</div>
</div>
{/* Port Mappings */}
<div>
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Port Mappings</label>
<StyledButton size="sm" variant="ghost" icon="IconPlus" onClick={addPort}>Add Port</StyledButton>
</div>
{ports.length === 0 && (
<p className="text-xs italic">No port mappings the app won't be accessible from a browser.</p>
)}
<div className="space-y-2">
{ports.map((p, idx) => (
<div key={idx} className="flex items-center gap-2 w-full">
<Input
name={`containerPort${idx}`}
label=''
type="number"
placeholder="Container port"
value={p.container}
onChange={(e) => updatePort(idx, 'container', e.target.value)}
className='w-full'
/>
<span className="text-xs"></span>
<Input
name={`hostPort${idx}`}
label=''
type="number"
placeholder="Host port (8600+)"
value={p.host}
onChange={(e) => updatePort(idx, 'host', e.target.value)}
className='w-full'
/>
<button
type="button"
onClick={() => removePort(idx)}
className="hover:text-desert-red transition-colors cursor-pointer"
>
<IconTrash className="w-5 h-5" />
</button>
</div>
))}
</div>
<p className="text-xs mt-2">Host ports should be in the 8600+ range. Custom apps get ports starting at {suggestedPort ?? 8600}.</p>
{checkingPreflight && (
<p className="text-xs mt-1 italic text-text-muted">Checking port availability</p>
)}
</div>
{/* Volume Mappings */}
<div>
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Volume Mounts</label>
<StyledButton size="sm" variant="ghost" icon="IconPlus" onClick={addVolume}>Add Volume</StyledButton>
</div>
{volumes.length === 0 && (
<p className="text-xs italic">No volumes data won't persist across restarts.</p>
)}
<div className="space-y-2">
{volumes.map((v, idx) => (
<div key={idx} className="flex items-center gap-2">
<Input
name={`hostPath${idx}`}
label=''
type="text"
placeholder="Host path (absolute)"
value={v.host_path}
onChange={(e) => updateVolume(idx, 'host_path', e.target.value)}
className='w-full'
/>
<span className="text-xs">:</span>
<Input
name={`containerPath${idx}`}
label=''
type="text"
placeholder="Container path"
value={v.container_path}
onChange={(e) => updateVolume(idx, 'container_path', e.target.value)}
className='w-full'
/>
<button
type="button"
onClick={() => removeVolume(idx)}
className="hover:text-desert-red transition-colors cursor-pointer"
>
<IconTrash className="w-5 h-5" />
</button>
</div>
))}
</div>
</div>
{/* Environment Variables */}
<div>
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Environment Variables</label>
<StyledButton size="sm" variant="ghost" icon="IconPlus" onClick={addEnv}>Add Variable</StyledButton>
</div>
<div className="space-y-2">
{envVars.map((e, idx) => (
<div key={idx} className="flex items-center gap-2">
<Input
name={`envVar${idx}`}
label=''
placeholder="KEY=value"
value={e.value}
onChange={(ev) => updateEnv(idx, ev.target.value)}
className='w-full font-mono'
/>
<button
type="button"
onClick={() => removeEnv(idx)}
className="hover:text-desert-red transition-colors cursor-pointer"
>
<IconTrash className="w-5 h-5" />
</button>
</div>
))}
{envVars.length === 0 && (
<p className="text-xs italic">No environment variables provided.</p>
)}
</div>
</div>
{/* Advanced: resource limits */}
<div>
<label className="text-sm font-medium">Resource Limits (optional)</label>
<div className="grid grid-cols-2 gap-4 mt-1">
<Input
name='memoryMb'
label=''
type="number"
placeholder="Memory (MB) — default 1024"
value={memoryMb}
onChange={(e) => setMemoryMb(e.target.value)}
className='w-full'
/>
<Input
name='cpus'
label=''
type="number"
placeholder="CPUs — default 1"
value={cpus}
onChange={(e) => setCpus(e.target.value)}
className='w-full'
/>
</div>
<p className="text-xs mt-1 italic">Caps prevent a runaway container from starving the host. Leave blank to use the defaults (1024 MB / 1 CPU).</p>
</div>
{/* Hard blocks — must be resolved before installing */}
{hasBlocks && (
<div className="space-y-2">
{blocked.map((b, i) => (
<Alert key={i} type="error" title="Not allowed" message={b} />
))}
</div>
)}
{/* Warnings */}
{hasWarnings && (
<div className="space-y-2">
{portConflicts.map((c) => (
<Alert
key={c.port}
type="warning"
title={`Port ${c.port} is already in use`}
message={`Currently bound by: ${c.usedBy}. Installation may fail.`}
/>
))}
{resourceWarnings.map((w, i) => (
<Alert key={i} type="warning" title="Resource warning" message={w} />
))}
<label className="flex items-center gap-2 cursor-pointer select-none mt-1">
<input
type="checkbox"
checked={forceInstall}
onChange={(e) => setForceInstall(e.target.checked)}
className="accent-desert-orange h-4 w-4 rounded"
/>
<span className="text-text-muted text-xs">I understand install anyway</span>
</label>
</div>
)}
<p className="text-sm">
Containers are created with <code className="font-mono">--restart=unless-stopped</code>. Data is not persisted unless you add volume mounts above.
</p>
</div>
</StyledModal>
)
}

View File

@ -8,7 +8,7 @@ interface LoadingSpinnerProps {
const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
text,
fullscreen = true,
fullscreen = false,
iconOnly = false,
light = false,
className,
@ -29,9 +29,12 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
}
return (
<div className={className}>
<div className="ui active inverted dimmer">
<div className="ui text loader">{!iconOnly && <span>{text || 'Loading'}</span>}</div>
<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>
</div>
)

View File

@ -0,0 +1,52 @@
import { useEffect, useState } from 'react'
import StyledModal from './StyledModal'
import api from '~/lib/api'
interface ServiceLogsModalProps {
serviceName: string
friendlyName: string
open: boolean
onClose: () => void
}
/** Shows the tail of a service container's logs with a manual refresh. */
export default function ServiceLogsModal({
serviceName,
friendlyName,
open,
onClose,
}: ServiceLogsModalProps) {
const [logs, setLogs] = useState('')
const [loading, setLoading] = useState(false)
async function load() {
setLoading(true)
const res = await api.getServiceLogs(serviceName, 500)
setLogs(res?.success ? res.logs || '' : 'Unable to load logs for this container.')
setLoading(false)
}
useEffect(() => {
if (open) load()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, serviceName])
return (
<StyledModal
title={`Logs — ${friendlyName}`}
open={open}
onCancel={onClose}
cancelText="Close"
onConfirm={load}
confirmText="Refresh"
confirmIcon="IconRefresh"
confirmVariant="outline"
confirmLoading={loading}
large
>
<pre className="text-xs font-mono whitespace-pre-wrap break-all max-h-[60vh] overflow-auto bg-surface-secondary rounded-md p-3 text-text-primary text-left">
{logs || (loading ? 'Loading…' : 'No log output.')}
</pre>
</StyledModal>
)
}

View File

@ -0,0 +1,102 @@
import { useEffect, useState } from 'react'
import StyledModal from './StyledModal'
import { formatBytes } from '~/lib/util'
import api from '~/lib/api'
interface Stats {
cpuPercent: number
memUsageBytes: number
memLimitBytes: number
memPercent: number
}
interface ServiceStatsModalProps {
serviceName: string
friendlyName: string
open: boolean
onClose: () => void
}
function Bar({ percent, label, value }: { percent: number; label: string; value: string }) {
const clamped = Math.min(100, Math.max(0, percent))
return (
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="font-medium text-text-primary">{label}</span>
<span className="text-text-muted font-mono">{value}</span>
</div>
<div className="h-2 w-full rounded-full bg-surface-secondary overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
clamped > 90 ? 'bg-desert-red' : clamped > 70 ? 'bg-desert-orange' : 'bg-desert-green'
}`}
style={{ width: `${clamped}%` }}
/>
</div>
</div>
)
}
/** Polls and displays live CPU/memory usage for a running service container. */
export default function ServiceStatsModal({
serviceName,
friendlyName,
open,
onClose,
}: ServiceStatsModalProps) {
const [stats, setStats] = useState<Stats | null>(null)
const [running, setRunning] = useState(true)
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!open) return
let cancelled = false
async function poll() {
const res = await api.getServiceStats(serviceName)
if (cancelled || !res) return
setRunning(res.running)
setStats(res.stats)
setLoading(false)
}
setLoading(true)
poll()
const id = setInterval(poll, 2000)
return () => {
cancelled = true
clearInterval(id)
}
}, [open, serviceName])
return (
<StyledModal
title={`Stats — ${friendlyName}`}
open={open}
onCancel={onClose}
cancelText="Close"
>
<div className="space-y-4 text-sm">
{!running ? (
<p className="text-text-muted text-center py-6">
This app is not running. Start it to see live resource usage.
</p>
) : !stats ? (
<p className="text-text-muted text-center py-6">
{loading ? 'Loading…' : 'No stats available.'}
</p>
) : (
<>
<Bar label="CPU" percent={stats.cpuPercent} value={`${stats.cpuPercent.toFixed(1)}%`} />
<Bar
label="Memory"
percent={stats.memPercent}
value={`${formatBytes(stats.memUsageBytes)} / ${formatBytes(stats.memLimitBytes)} (${stats.memPercent.toFixed(1)}%)`}
/>
<p className="text-xs text-text-muted">Updates every 2 seconds.</p>
</>
)}
</div>
</StyledModal>
)
}

View File

@ -13,6 +13,7 @@ export type StyledModalProps = {
confirmIcon?: StyledButtonProps['icon']
confirmVariant?: StyledButtonProps['variant']
confirmLoading?: boolean
confirmDisabled?: boolean
open: boolean
onCancel?: () => void
onConfirm?: () => void
@ -33,6 +34,7 @@ const StyledModal: React.FC<StyledModalProps> = ({
confirmIcon,
confirmVariant = 'action',
confirmLoading = false,
confirmDisabled = false,
onCancel,
onConfirm,
icon,
@ -94,6 +96,7 @@ const StyledModal: React.FC<StyledModalProps> = ({
}}
icon={confirmIcon}
loading={confirmLoading}
disabled={confirmDisabled}
>
{confirmText}
</StyledButton>

View File

@ -0,0 +1,95 @@
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,9 +10,19 @@ 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 { IconX } from '@tabler/icons-react'
import { formatBytes } from '~/lib/util'
import {
IconArrowsSort,
IconDownload,
IconEye,
IconSortAscending,
IconSortDescending,
IconX,
} from '@tabler/icons-react'
import { useModals } from '~/context/ModalContext'
import StyledModal from '../StyledModal'
import ActiveEmbedJobs from '~/components/ActiveEmbedJobs'
@ -23,6 +33,42 @@ 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
@ -101,6 +147,8 @@ 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()
@ -214,6 +262,20 @@ 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: () => {
@ -310,6 +372,31 @@ 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
@ -474,7 +561,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
isActive
? 'bg-desert-green text-white'
: 'bg-surface-primary text-text-secondary hover:bg-surface-tertiary'
} ${updateIngestPolicyMutation.isPending ? 'opacity-50 cursor-not-allowed' : ''}`}
} ${updateIngestPolicyMutation.isPending ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
>
{option}
</button>
@ -485,18 +572,33 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
</div>
<div className="my-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between mb-4 gap-2 flex-wrap">
<StyledSectionHeader title="Processing Queue" className="!mb-0" />
<StyledButton
variant="danger"
size="md"
icon="IconTrash"
onClick={() => cleanupFailedMutation.mutate()}
loading={cleanupFailedMutation.isPending}
disabled={cleanupFailedMutation.isPending || qdrantOffline}
>
Clean Up Failed
</StyledButton>
<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>
</div>
<ActiveEmbedJobs withHeader={false} />
</div>
@ -555,7 +657,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
columns={[
{
accessor: 'source',
title: 'File Name',
title: renderSortHeader('File Name', 'name', sort, setSort),
render(record) {
const warnings = fileWarnings[record.source] ?? []
const pill = renderStatePill(record)
@ -594,6 +696,35 @@ 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: '',
@ -642,6 +773,9 @@ 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 && (
@ -662,6 +796,24 @@ 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"
@ -675,7 +827,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
},
},
]}
data={groupAndSortKbFiles(storedFiles)}
data={groupAndSortKbFiles(storedFiles, sort)}
loading={isLoadingFiles}
/>
</div>
@ -807,6 +959,57 @@ 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

@ -0,0 +1,106 @@
import classNames from "classnames";
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from "@headlessui/react";
import { IconChevronDown } from "@tabler/icons-react";
export interface SelectOption<T = string> {
value: T;
label: string;
disabled?: boolean;
}
export interface SelectProps<T = string> {
name: string;
label: string;
value: T;
onChange: (value: T) => void;
options: SelectOption<T>[];
helpText?: string;
placeholder?: string;
className?: string;
labelClassName?: string;
selectClassName?: string;
containerClassName?: string;
error?: boolean;
required?: boolean;
disabled?: boolean;
}
const Select = <T,>({
name,
label,
value,
onChange,
options,
helpText,
placeholder,
className,
labelClassName,
selectClassName,
containerClassName,
error,
required,
disabled,
}: SelectProps<T>) => {
const selectedOption = options.find((o) => o.value === value);
return (
<div className={classNames(className)}>
<label
htmlFor={name}
className={classNames("block text-base/6 font-medium text-text-primary", labelClassName)}
>
{label}{required ? "*" : ""}
</label>
{helpText && <p className="mt-1 text-sm text-text-muted">{helpText}</p>}
<div className={classNames("mt-1.5", containerClassName)}>
<Listbox value={value} onChange={onChange} disabled={disabled}>
<div className="relative">
<ListboxButton
id={name}
className={classNames(
"flex items-center w-full rounded-md bg-surface-primary px-3 py-2 text-base border border-border-default focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-primary sm:text-sm/6 text-left",
selectedOption ? "text-text-primary" : "text-text-muted",
error ? "!border-red-500 focus:outline-red-500 !bg-red-100" : "",
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
selectClassName
)}
>
<span className="flex-1 truncate">
{selectedOption ? selectedOption.label : (placeholder ?? label)}
</span>
<IconChevronDown className="w-4 h-4 text-text-muted ml-2 shrink-0 transition-transform duration-150 group-data-[open]:rotate-180 data-[open]:rotate-180 ui-open:rotate-180" />
</ListboxButton>
<ListboxOptions
transition
anchor="bottom start"
className={classNames(
"z-50 w-[var(--button-width)] rounded-md bg-surface-primary border border-border-default shadow-lg max-h-60 overflow-auto",
"transition duration-100 ease-out data-[closed]:opacity-0 data-[closed]:scale-95",
"mt-1 focus:outline-none"
)}
>
{options.map((option, index) => (
<ListboxOption
key={index}
value={option.value}
disabled={option.disabled}
className={classNames(
"px-3 py-2 text-sm text-text-primary select-none",
option.disabled
? "opacity-40 cursor-not-allowed"
: "cursor-pointer data-[focus]:bg-surface-secondary data-[selected]:font-medium data-[selected]:text-primary"
)}
>
{option.label}
</ListboxOption>
))}
</ListboxOptions>
</div>
</Listbox>
</div>
</div>
);
};
export default Select;

View File

@ -28,6 +28,38 @@ type MapComponentProps = {
showCoordinatesEnabled: boolean
}
const SAVED_MAP_VIEW_KEY = 'nomad:map-view'
const DEFAULT_MAP_VIEW = { longitude: -101, latitude: 40, zoom: 3.5 }
type SavedMapView = { longitude: number; latitude: number; zoom: number }
// Restore the last map position/zoom from localStorage so a refresh of /maps doesn't snap back
// to the default US-wide view. Bounds-checked so a corrupt or out-of-range value falls through
// to the default instead of throwing.
const getSavedMapView = (): SavedMapView | null => {
try {
const raw = localStorage.getItem(SAVED_MAP_VIEW_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
if (
parsed &&
typeof parsed === 'object' &&
Number.isFinite(parsed.longitude) &&
Number.isFinite(parsed.latitude) &&
Number.isFinite(parsed.zoom) &&
parsed.latitude >= -90 &&
parsed.latitude <= 90 &&
parsed.longitude >= -180 &&
parsed.longitude <= 180
) {
return { longitude: parsed.longitude, latitude: parsed.latitude, zoom: parsed.zoom }
}
} catch {
// ignore — fall through to default
}
return null
}
export default function MapComponent({
isHoveringUI,
showCoordinatesEnabled,
@ -47,6 +79,10 @@ export default function MapComponent({
() => (localStorage.getItem('nomad:map-scale-unit') as ScaleUnit) || 'metric'
)
// Resolve the initial view once at mount: saved view → default. Lazy so it isn't recomputed
// on every render.
const [initialViewState] = useState(() => getSavedMapView() ?? DEFAULT_MAP_VIEW)
const [cursorLngLat, setCursorLngLat] = useState<{
lng: number
lat: number
@ -171,10 +207,18 @@ export default function MapComponent({
cursor={isDraggingMap ? 'grabbing' : 'crosshair'}
mapStyle={`${window.location.protocol}//${window.location.hostname}:${window.location.port}/api/maps/styles`}
mapLib={maplibregl}
initialViewState={{
longitude: -101,
latitude: 40,
zoom: 3.5,
initialViewState={initialViewState}
onMoveEnd={(e) => {
// Persist the view so a refresh restores where the user was, not the default.
const { longitude, latitude, zoom } = e.viewState
try {
localStorage.setItem(
SAVED_MAP_VIEW_KEY,
JSON.stringify({ longitude, latitude, zoom })
)
} catch {
// ignore persistence failures (private mode, quota)
}
}}
onMouseDown={() => {
setIsDraggingMap(true)

View File

@ -0,0 +1,101 @@
import StyledSectionHeader from '~/components/StyledSectionHeader'
import Switch from '~/components/inputs/Switch'
import api from '~/lib/api'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useNotifications } from '~/context/NotificationContext'
import { useAppAutoUpdateStatus } from '~/hooks/useAppAutoUpdateStatus'
export default function AppAutoUpdateSection() {
const { addNotification } = useNotifications()
const queryClient = useQueryClient()
const { data: status, isLoading } = useAppAutoUpdateStatus()
const enabled = status?.enabled ?? false
const toggleMutation = useMutation({
mutationFn: (value: boolean) => api.updateSetting('appAutoUpdate.enabled', value),
onSuccess: (_data, value) => {
queryClient.invalidateQueries({ queryKey: ['app-auto-update-status'] })
addNotification({
type: 'success',
message: value ? 'App automatic updates enabled.' : 'App automatic updates disabled.',
})
},
onError: () => {
addNotification({ type: 'error', message: 'Failed to update app auto-update setting.' })
},
})
return (
<>
<StyledSectionHeader title="Automatic App Updates" className="mt-8" />
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden mt-6 p-6">
<Switch
checked={enabled}
onChange={(value) => toggleMutation.mutate(value)}
disabled={toggleMutation.isPending || isLoading}
label="Enable Automatic App Updates"
description="Automatically install minor and patch updates for apps you've opted in (toggle each app in Supply Depot). Major versions always require a manual update. Uses the same update window and cool-off period as the core schedule above."
/>
{enabled && status && (
<div className="mt-6 pt-4 border-t border-desert-stone-light text-sm">
<p className="text-desert-stone mb-3">
<span className="font-medium">Update window: </span>
{status.windowStart}{status.windowEnd} (
{status.withinWindow ? 'currently inside' : 'currently outside'}); cool-off{' '}
{status.cooloffHours}h.
{status.lastResult && (
<>
{' '}
<span className="font-medium">Last run: </span>
{status.lastResult}
{status.lastAttemptAt
? ` (${new Date(status.lastAttemptAt).toLocaleString()})`
: ''}
</>
)}
</p>
{status.apps.length === 0 ? (
<p className="text-desert-stone-dark">
No apps are opted in yet. Enable auto-update on individual apps from the Supply Depot.
</p>
) : (
<ul className="space-y-2">
{status.apps.map((app) => (
<li
key={app.service_name}
className="flex items-start justify-between gap-4 rounded-md bg-surface-secondary px-3 py-2"
>
<div>
<p className="font-medium text-text-primary">
{app.friendly_name || app.service_name}
</p>
<p className="text-desert-stone">
{app.current_version}
{app.available_update_version
? `${app.available_update_version}`
: ' (up to date)'}
</p>
{app.auto_disabled_reason && (
<p className="text-desert-red mt-0.5">{app.auto_disabled_reason}</p>
)}
</div>
<span
className={`shrink-0 text-xs font-medium ${
app.eligible ? 'text-desert-green' : 'text-desert-stone'
}`}
>
{app.reason}
</span>
</li>
))}
</ul>
)}
</div>
)}
</div>
</>
)
}

View File

@ -0,0 +1,249 @@
import { useEffect, useState } from 'react'
import StyledButton from '~/components/StyledButton'
import StyledSectionHeader from '~/components/StyledSectionHeader'
import Alert from '~/components/Alert'
import api from '~/lib/api'
import Input from '~/components/inputs/Input'
import Switch from '~/components/inputs/Switch'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useNotifications } from '~/context/NotificationContext'
import { useContentAutoUpdateStatus } from '~/hooks/useContentAutoUpdateStatus'
import { formatBytes } from '~/lib/util'
const COOLOFF_OPTIONS = [
{ value: 24, label: '24 hours (1 day)' },
{ value: 48, label: '48 hours (2 days)' },
{ value: 72, label: '72 hours (3 days)' },
{ value: 168, label: '7 days' },
]
const BYTES_PER_GB = 1024 * 1024 * 1024
export default function ContentAutoUpdateSection() {
const { addNotification } = useNotifications()
const queryClient = useQueryClient()
const { data: status, isLoading } = useContentAutoUpdateStatus()
const [windowStart, setWindowStart] = useState('02:00')
const [windowEnd, setWindowEnd] = useState('05:00')
const [cooloff, setCooloff] = useState(72)
// Data cap is stored in bytes but edited in GB (0 = unlimited).
const [capGb, setCapGb] = useState('0')
// Seed editable fields once the persisted status loads.
useEffect(() => {
if (status) {
setWindowStart(status.windowStart)
setWindowEnd(status.windowEnd)
setCooloff(status.cooloffHours)
setCapGb(
status.maxBytesPerWindow > 0
? String(Math.round((status.maxBytesPerWindow / BYTES_PER_GB) * 100) / 100)
: '0'
)
}
}, [status?.windowStart, status?.windowEnd, status?.cooloffHours, status?.maxBytesPerWindow])
const enabled = status?.enabled ?? false
const autoDisabled = !!status?.autoDisabledReason
const toggleMutation = useMutation({
mutationFn: (value: boolean) => api.updateSetting('contentAutoUpdate.enabled', value),
onSuccess: (_data, value) => {
queryClient.invalidateQueries({ queryKey: ['content-auto-update-status'] })
addNotification({
type: 'success',
message: value
? 'Automatic content updates enabled.'
: 'Automatic content updates disabled.',
})
},
onError: () => {
addNotification({ type: 'error', message: 'Failed to update content auto-update setting.' })
},
})
const handleSaveSchedule = async () => {
const parsedGb = Number(capGb)
if (!Number.isFinite(parsedGb) || parsedGb < 0) {
addNotification({ type: 'error', message: 'Data cap must be 0 or a positive number of GB.' })
return
}
const capBytes = Math.round(parsedGb * BYTES_PER_GB)
try {
await api.updateSetting('contentAutoUpdate.windowStart', windowStart)
await api.updateSetting('contentAutoUpdate.windowEnd', windowEnd)
await api.updateSetting('contentAutoUpdate.cooloffHours', String(cooloff))
await api.updateSetting('contentAutoUpdate.maxBytesPerWindow', String(capBytes))
queryClient.invalidateQueries({ queryKey: ['content-auto-update-status'] })
addNotification({ type: 'success', message: 'Content update schedule saved.' })
} catch {
addNotification({ type: 'error', message: 'Failed to save content update schedule.' })
}
}
return (
<>
<StyledSectionHeader title="Automatic Content Updates" className="mt-8" />
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden mt-6 p-6">
{autoDisabled && (
<Alert
type="warning"
title="Automatic Content Updates Disabled"
message={
status?.autoDisabledReason ||
'Automatic content updates were disabled after repeated failures.'
}
variant="bordered"
className="mb-4"
/>
)}
<Switch
checked={enabled}
onChange={(value) => toggleMutation.mutate(value)}
disabled={toggleMutation.isPending || isLoading}
label="Enable Automatic Content Updates"
description="Automatically download newer versions of your installed Information Library content (ZIM files) and maps during your chosen window. Content downloads can be very large, so set a per-window data cap to limit how much is pulled at once. We recommend allowing at least 0.5 GB per update window to ensure most updates can be pulled in a timely manner, but you can set a lower cap if you have very limited bandwidth and don't mind some updates being skipped (they will still appear in the UI and can be updated manually). If an update repeatedly fails to download within the window, it will be automatically disabled and require manual intervention to re-enable."
/>
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Input
name="contentWindowStart"
label="Window Start"
type="time"
value={windowStart}
onChange={(e) => setWindowStart(e.target.value)}
disabled={!enabled}
helpText="Local server time"
/>
<Input
name="contentWindowEnd"
label="Window End"
type="time"
value={windowEnd}
onChange={(e) => setWindowEnd(e.target.value)}
disabled={!enabled}
helpText="Local server time"
/>
<div>
<label
htmlFor="contentCooloff"
className="block text-base/6 font-medium text-text-primary"
>
Cool-off Period
</label>
<p className="mt-1 text-sm text-text-muted">Delay after a new version appears</p>
<select
id="contentCooloff"
value={cooloff}
onChange={(e) => setCooloff(Number(e.target.value))}
disabled={!enabled}
className="mt-1.5 block w-full rounded-md bg-surface-primary px-3 py-2 text-base text-text-primary border border-border-default focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-primary sm:text-sm/6 disabled:opacity-50"
>
{COOLOFF_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<Input
name="contentDataCap"
label="Data Cap (GB)"
type="number"
min="0"
step="1"
value={capGb}
onChange={(e) => setCapGb(e.target.value)}
disabled={!enabled}
helpText="Per window. 0 = unlimited"
/>
</div>
<div className="mt-4 flex justify-end">
<StyledButton variant="primary" size="sm" onClick={handleSaveSchedule} disabled={!enabled}>
Save Schedule
</StyledButton>
</div>
{enabled && status && (
<div className="mt-6 pt-4 border-t border-desert-stone-light text-sm">
<p className="text-desert-stone mb-3">
<span className="font-medium">Update window: </span>
{status.windowStart}{status.windowEnd} (
{status.withinWindow ? 'currently inside' : 'currently outside'}); cool-off{' '}
{status.cooloffHours}h; data cap{' '}
{status.maxBytesPerWindow > 0 ? formatBytes(status.maxBytesPerWindow) : 'unlimited'}
{status.maxBytesPerWindow > 0 && (
<> ({formatBytes(status.windowBytesUsed)} used this window)</>
)}
.
{status.lastResult && (
<>
{' '}
<span className="font-medium">Last run: </span>
{status.lastResult}
{status.lastAttemptAt
? ` (${new Date(status.lastAttemptAt).toLocaleString()})`
: ''}
</>
)}
</p>
{status.lastError && (
<p className="text-desert-red mb-3">
<span className="font-medium">Last error: </span>
{status.lastError}
</p>
)}
{status.resources.length === 0 ? (
<p className="text-desert-stone-dark">
All installed content is up to date. New versions will appear here when detected.
</p>
) : (
<ul className="space-y-2">
{status.resources.map((resource) => (
<li
key={`${resource.resource_type}:${resource.resource_id}`}
className="flex items-start justify-between gap-4 rounded-md bg-surface-secondary px-3 py-2"
>
<div>
<p className="font-medium text-text-primary">
{resource.resource_id}{' '}
<span className="text-xs uppercase text-desert-stone">
{resource.resource_type}
</span>
</p>
<p className="text-desert-stone">
{resource.current_version}
{resource.available_update_version
? `${resource.available_update_version}`
: ' (up to date)'}
{resource.size_bytes ? ` · ${formatBytes(resource.size_bytes)}` : ''}
</p>
{resource.auto_disabled_reason && (
<p className="text-desert-red mt-0.5">{resource.auto_disabled_reason}</p>
)}
</div>
<span
className={`shrink-0 text-xs font-medium ${resource.exceeds_cap
? 'text-desert-red'
: resource.eligible
? 'text-desert-green'
: 'text-desert-stone'
}`}
>
{resource.exceeds_cap ? 'Skipped — exceeds data cap, update manually' : resource.reason}
</span>
</li>
))}
</ul>
)}
</div>
)}
</div>
</>
)
}

View File

@ -0,0 +1,227 @@
import { useState } from 'react'
import StyledButton from '~/components/StyledButton'
import StyledTable from '~/components/StyledTable'
import StyledSectionHeader from '~/components/StyledSectionHeader'
import ActiveDownloads from '~/components/ActiveDownloads'
import Alert from '~/components/Alert'
import type { ContentUpdateCheckResult, ResourceUpdateInfo } from '../../../types/collections'
import api from '~/lib/api'
import { useQueryClient } from '@tanstack/react-query'
import { useNotifications } from '~/context/NotificationContext'
import { formatBytes } from '~/lib/util'
export default function ContentUpdatesSection() {
const { addNotification } = useNotifications()
const queryClient = useQueryClient()
const [checkResult, setCheckResult] = useState<ContentUpdateCheckResult | null>(null)
const [isChecking, setIsChecking] = useState(false)
const [applyingIds, setApplyingIds] = useState<Set<string>>(new Set())
const [isApplyingAll, setIsApplyingAll] = useState(false)
const handleCheck = async () => {
setIsChecking(true)
try {
const result = await api.checkForContentUpdates()
if (result) {
setCheckResult(result)
}
} catch {
setCheckResult({
updates: [],
checked_at: new Date().toISOString(),
error: 'Failed to check for content updates',
})
} finally {
setIsChecking(false)
}
}
const handleApply = async (update: ResourceUpdateInfo) => {
setApplyingIds((prev) => new Set(prev).add(update.resource_id))
try {
const result = await api.applyContentUpdate(update)
if (result?.success) {
addNotification({ type: 'success', message: `Update started for ${update.resource_id}` })
// Remove from the updates list
setCheckResult((prev) =>
prev
? { ...prev, updates: prev.updates.filter((u) => u.resource_id !== update.resource_id) }
: prev
)
// Force Active Downloads to refetch now — small updates finish before the next
// idle poll fires, so without this the user wouldn't see them.
queryClient.invalidateQueries({ queryKey: ['download-jobs'] })
} else {
addNotification({ type: 'error', message: result?.error || 'Failed to start update' })
}
} catch {
addNotification({ type: 'error', message: `Failed to start update for ${update.resource_id}` })
} finally {
setApplyingIds((prev) => {
const next = new Set(prev)
next.delete(update.resource_id)
return next
})
}
}
const handleApplyAll = async () => {
if (!checkResult?.updates.length) return
setIsApplyingAll(true)
try {
const result = await api.applyAllContentUpdates(checkResult.updates)
if (result?.results) {
const succeeded = result.results.filter((r) => r.success).length
const failed = result.results.filter((r) => !r.success).length
if (succeeded > 0) {
addNotification({ type: 'success', message: `Started ${succeeded} update(s)` })
}
if (failed > 0) {
addNotification({ type: 'error', message: `${failed} update(s) could not be started` })
}
// Remove successful updates from the list
const successIds = new Set(result.results.filter((r) => r.success).map((r) => r.resource_id))
setCheckResult((prev) =>
prev
? { ...prev, updates: prev.updates.filter((u) => !successIds.has(u.resource_id)) }
: prev
)
if (successIds.size > 0) {
queryClient.invalidateQueries({ queryKey: ['download-jobs'] })
}
}
} catch {
addNotification({ type: 'error', message: 'Failed to apply updates' })
} finally {
setIsApplyingAll(false)
}
}
return (
<div className="mt-8">
<StyledSectionHeader title="Manual Content Updates" />
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden p-6">
<div className="flex items-center justify-between">
<p className="text-desert-stone-dark">
Check if newer versions of your installed ZIM files and maps are available.
</p>
<StyledButton
variant="primary"
icon="IconRefresh"
onClick={handleCheck}
loading={isChecking}
>
Check for Content Updates
</StyledButton>
</div>
{checkResult?.error && (
<Alert
type="warning"
title="Update Check Issue"
message={checkResult.error}
variant="bordered"
className="my-4"
/>
)}
{checkResult && !checkResult.error && checkResult.updates.length === 0 && (
<Alert
type="success"
title="All Content Up to Date"
message="All your installed content is running the latest available version."
variant="bordered"
className="my-4"
/>
)}
{checkResult && checkResult.updates.length > 0 && (
<div className="mt-4">
<div className="flex items-center justify-between mb-3">
<p className="text-sm text-desert-stone-dark">
{checkResult.updates.length} update(s) available
</p>
<StyledButton
variant="primary"
size="sm"
icon="IconDownload"
onClick={handleApplyAll}
loading={isApplyingAll}
>
Update All ({checkResult.updates.length})
</StyledButton>
</div>
<StyledTable
data={checkResult.updates}
columns={[
{
accessor: 'resource_id',
title: 'Title',
render: (record) => (
<span className="font-medium text-desert-green">{record.resource_id}</span>
),
},
{
accessor: 'resource_type',
title: 'Type',
render: (record) => (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${record.resource_type === 'zim'
? 'bg-blue-100 text-blue-800'
: 'bg-emerald-100 text-emerald-800'
}`}
>
{record.resource_type === 'zim' ? 'ZIM' : 'Map'}
</span>
),
},
{
accessor: 'size_bytes',
title: 'Size',
render: (record) => (
<span className="text-desert-stone-dark">
{record.size_bytes ? formatBytes(record.size_bytes, 1) : '—'}
</span>
),
},
{
accessor: 'installed_version',
title: 'Version',
render: (record) => (
<span className="text-desert-stone-dark">
{record.installed_version} {record.latest_version}
</span>
),
},
{
accessor: 'resource_id',
title: '',
render: (record) => (
<StyledButton
variant="secondary"
size="sm"
icon="IconDownload"
onClick={() => handleApply(record)}
loading={applyingIds.has(record.resource_id)}
>
Update
</StyledButton>
),
},
]}
/>
</div>
)}
{checkResult?.checked_at && (
<p className="text-xs text-desert-stone mt-3">
Last checked: {new Date(checkResult.checked_at).toLocaleString()}
</p>
)}
</div>
<ActiveDownloads withHeader />
</div>
)
}

View File

@ -0,0 +1,185 @@
import { useEffect, useState } from 'react'
import StyledButton from '~/components/StyledButton'
import StyledSectionHeader from '~/components/StyledSectionHeader'
import Alert from '~/components/Alert'
import api from '~/lib/api'
import Input from '~/components/inputs/Input'
import Switch from '~/components/inputs/Switch'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useNotifications } from '~/context/NotificationContext'
import { useAutoUpdateStatus } from '~/hooks/useAutoUpdateStatus'
const COOLOFF_OPTIONS = [
{ value: 24, label: '24 hours (1 day)' },
{ value: 48, label: '48 hours (2 days)' },
{ value: 72, label: '72 hours (3 days)' },
{ value: 168, label: '7 days' },
]
export default function CoreAutoUpdateSection() {
const { addNotification } = useNotifications()
const queryClient = useQueryClient()
const { data: status, isLoading } = useAutoUpdateStatus()
const [windowStart, setWindowStart] = useState('02:00')
const [windowEnd, setWindowEnd] = useState('05:00')
const [cooloff, setCooloff] = useState(72)
// Seed editable fields once the persisted status loads.
useEffect(() => {
if (status) {
setWindowStart(status.windowStart)
setWindowEnd(status.windowEnd)
setCooloff(status.cooloffHours)
}
}, [status?.windowStart, status?.windowEnd, status?.cooloffHours])
const saveMutation = useMutation({
mutationFn: ({ key, value }: { key: string; value: any }) => api.updateSetting(key, value),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['auto-update-status'] })
},
onError: () => {
addNotification({ type: 'error', message: 'Failed to update auto-update setting.' })
},
})
const enabled = status?.enabled ?? false
const autoDisabled = !!status?.autoDisabledReason
const handleToggle = (value: boolean) => {
saveMutation.mutate(
{ key: 'autoUpdate.enabled', value },
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['auto-update-status'] })
addNotification({
type: 'success',
message: value ? 'Automatic updates enabled.' : 'Automatic updates disabled.',
})
},
}
)
}
const handleSaveWindow = async () => {
try {
await api.updateSetting('autoUpdate.windowStart', windowStart)
await api.updateSetting('autoUpdate.windowEnd', windowEnd)
await api.updateSetting('autoUpdate.cooloffHours', String(cooloff))
queryClient.invalidateQueries({ queryKey: ['auto-update-status'] })
addNotification({ type: 'success', message: 'Auto-update schedule saved.' })
} catch {
addNotification({ type: 'error', message: 'Failed to save auto-update schedule.' })
}
}
return (
<>
<StyledSectionHeader title="Automatic Core Updates" className="mt-8" />
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden mt-6 p-6">
{autoDisabled && (
<Alert
type="warning"
title="Automatic Core Updates Disabled"
message={status?.autoDisabledReason || 'Automatic core updates were disabled after repeated failures.'}
variant="bordered"
className="mb-4"
/>
)}
<Switch
checked={enabled}
onChange={handleToggle}
disabled={saveMutation.isPending || isLoading}
label="Enable Automatic Core Updates"
description="Automatically install minor and patch updates during your chosen window. Major versions always require a manual update due to their potentially breaking nature."
/>
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-4">
<Input
name="autoUpdateWindowStart"
label="Window Start"
type="time"
value={windowStart}
onChange={(e) => setWindowStart(e.target.value)}
disabled={!enabled}
helpText="Local server time"
/>
<Input
name="autoUpdateWindowEnd"
label="Window End"
type="time"
value={windowEnd}
onChange={(e) => setWindowEnd(e.target.value)}
disabled={!enabled}
helpText="Local server time"
/>
<div>
<label
htmlFor="autoUpdateCooloff"
className="block text-base/6 font-medium text-text-primary"
>
Cool-off Period
</label>
<p className="mt-1 text-sm text-text-muted">Delay after a release is published</p>
<select
id="autoUpdateCooloff"
value={cooloff}
onChange={(e) => setCooloff(Number(e.target.value))}
disabled={!enabled}
className="mt-1.5 block w-full rounded-md bg-surface-primary px-3 py-2 text-base text-text-primary border border-border-default focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-primary sm:text-sm/6 disabled:opacity-50"
>
{COOLOFF_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
<div className="mt-4 flex justify-end">
<StyledButton
variant="primary"
size="sm"
onClick={handleSaveWindow}
disabled={!enabled}
>
Save Schedule
</StyledButton>
</div>
{enabled && status && (
<div className="mt-6 pt-4 border-t border-desert-stone-light text-sm space-y-1">
<p className="text-desert-stone-dark">
<span className="font-medium">Status: </span>
{status.eligibleTarget
? `Eligible update ready: ${status.eligibleTarget.version}`
: 'No eligible update — system is current or the latest release is a major version / still in cool-off.'}
</p>
<p className="text-desert-stone">
<span className="font-medium">Update window: </span>
{status.withinWindow ? 'Currently inside the window' : 'Currently outside the window'}
</p>
{status.lastResult && (
<p className="text-desert-stone">
<span className="font-medium">Last check: </span>
{status.lastResult}
{status.lastAttemptAt
? ` (${new Date(status.lastAttemptAt).toLocaleString()})`
: ''}
</p>
)}
{status.lastError && (
<p className="text-desert-red">
<span className="font-medium">Last error: </span>
{status.lastError}
</p>
)}
</div>
)}
</div>
</>
)
}

View File

@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import api from '~/lib/api'
import { AppAutoUpdateStatus } from '../../types/system'
export const useAppAutoUpdateStatus = () => {
return useQuery<AppAutoUpdateStatus | undefined>({
queryKey: ['app-auto-update-status'],
queryFn: () => api.getAppAutoUpdateStatus(),
refetchOnWindowFocus: false,
})
}

View File

@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import api from '~/lib/api'
import { AutoUpdateStatus } from '../../types/system'
export const useAutoUpdateStatus = () => {
return useQuery<AutoUpdateStatus | undefined>({
queryKey: ['auto-update-status'],
queryFn: () => api.getAutoUpdateStatus(),
refetchOnWindowFocus: false,
})
}

View File

@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import api from '~/lib/api'
import { ContentAutoUpdateStatus } from '../../types/system'
export const useContentAutoUpdateStatus = () => {
return useQuery<ContentAutoUpdateStatus | undefined>({
queryKey: ['content-auto-update-status'],
queryFn: () => api.getContentAutoUpdateStatus(),
refetchOnWindowFocus: false,
})
}

View File

@ -1,5 +1,7 @@
import {
IconAdjustments,
IconArrowBigUpLines,
IconBox,
IconChartBar,
IconDashboard,
IconFolder,
@ -7,7 +9,6 @@ import {
IconHeart,
IconMapRoute,
IconSettings,
IconTerminal2,
IconWand,
IconZoom
} from '@tabler/icons-react'
@ -23,7 +24,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
const navigation = [
...(aiAssistantInstallStatus.isInstalled ? [{ name: aiAssistantName, href: '/settings/models', icon: IconWand, current: false }] : []),
{ name: 'Apps', href: '/settings/apps', icon: IconTerminal2, current: false },
{ name: 'Supply Depot', href: '/supply-depot', icon: IconBox, 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 },
@ -42,6 +43,7 @@ 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

@ -2,7 +2,7 @@ import axios, { AxiosError, AxiosInstance } from 'axios'
import { ListRemoteZimFilesResponse, ListZimFilesResponse } from '../../types/zim'
import { ServiceSlim } from '../../types/services'
import { FileEntry } from '../../types/files'
import { CheckLatestVersionResult, SystemInformationResponse, SystemUpdateStatus } from '../../types/system'
import { AppAutoUpdateStatus, AutoUpdateStatus, CheckLatestVersionResult, ContentAutoUpdateStatus, SystemInformationResponse, SystemUpdateStatus } from '../../types/system'
import { DownloadJobWithProgress, WikipediaState } from '../../types/downloads'
import type { Country, CountryCode, CountryGroup, MapExtractPreflight } from '../../types/maps'
import { EmbedJobWithProgress, FileWarningsResult, StoredFileInfo } from '../../types/rag'
@ -479,6 +479,13 @@ 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')
@ -514,6 +521,17 @@ 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')
@ -542,6 +560,39 @@ class API {
})()
}
async getAutoUpdateStatus() {
return catchInternal(async () => {
const response = await this.client.get<AutoUpdateStatus>('/system/auto-update/status')
return response.data
})()
}
async getAppAutoUpdateStatus() {
return catchInternal(async () => {
const response = await this.client.get<AppAutoUpdateStatus>('/system/apps/auto-update/status')
return response.data
})()
}
async getContentAutoUpdateStatus() {
return catchInternal(async () => {
const response = await this.client.get<ContentAutoUpdateStatus>(
'/system/content/auto-update/status'
)
return response.data
})()
}
async setServiceAutoUpdate(serviceName: string, enabled: boolean) {
return catchInternal(async () => {
const response = await this.client.post<{ success: boolean; message: string }>(
'/system/services/auto-update',
{ service_name: serviceName, enabled }
)
return response.data
})()
}
async healthCheck() {
return catchInternal(async () => {
const response = await this.client.get<{ status: string }>('/health', {
@ -762,6 +813,18 @@ class API {
})()
}
async rescanZimLibrary() {
return catchInternal(async () => {
const response = await this.client.post<{
message: string
before: number
after: number
added: number
}>('/zim/rescan-library')
return response.data
})()
}
async listDownloadJobs(filetype?: string): Promise<DownloadJobWithProgress[] | undefined> {
return catchInternal(async () => {
const endpoint = filetype ? `/downloads/jobs/${filetype}` : '/downloads/jobs'
@ -957,6 +1020,171 @@ class API {
return response.data
})()
}
async preflightCheck(service_name: string) {
return catchInternal(async () => {
const response = await this.client.get<{
portConflicts: Array<{ port: number; usedBy: string }>
resourceWarnings: string[]
}>('/system/services/preflight', { params: { service_name } })
return response.data
})()
}
async suggestCustomPort() {
return catchInternal(async () => {
const response = await this.client.get<{ port: number }>('/system/services/suggest-port')
return response.data
})()
}
async preflightCustomApp(payload: {
image?: string
ports?: number[]
volumes?: Array<{ host_path: string; container_path: string }>
exclude_service?: string
}) {
return catchInternal(async () => {
const response = await this.client.post<{
portConflicts: Array<{ port: number; usedBy: string }>
resourceWarnings: string[]
blocked: string[]
}>('/system/services/preflight-custom', payload)
return response.data
})()
}
async createCustomApp(payload: {
friendly_name: string
image: string
ports?: Array<{ container: number; host: number }>
volumes?: Array<{ host_path: string; container_path: string }>
env?: string[]
category?: string
icon?: string
memory_mb?: number
cpus?: number
force?: boolean
}) {
return catchInternal(async () => {
const response = await this.client.post<{
success: boolean
message: string
service_name: string
}>('/system/services/custom', payload)
return response.data
})()
}
async setServiceCustomUrl(service_name: string, custom_url: string | null) {
return catchInternal(async () => {
const response = await this.client.put<{ success: boolean; custom_url: string | null }>(
'/system/services/custom-url',
{ service_name, custom_url }
)
return response.data
})()
}
async deleteCustomApp(service_name: string, remove_image = false) {
return catchInternal(async () => {
const response = await this.client.delete<{ success: boolean; message: string }>(
'/system/services/custom',
{ data: { service_name, remove_image } }
)
return response.data
})()
}
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 }>(
'/system/services/custom/update',
{ service_name }
)
return response.data
})()
}
async getServiceLogs(service_name: string, tail = 200) {
return catchInternal(async () => {
const response = await this.client.get<{ success: boolean; logs: string }>(
`/system/services/${service_name}/logs`,
{ params: { tail } }
)
return response.data
})()
}
async getServiceStats(service_name: string) {
return catchInternal(async () => {
const response = await this.client.get<{
success: boolean
running: boolean
stats: {
cpuPercent: number
memUsageBytes: number
memLimitBytes: number
memPercent: number
} | null
}>(`/system/services/${service_name}/stats`)
return response.data
})()
}
async getCustomApp(service_name: string) {
return catchInternal(async () => {
const response = await this.client.get<{
success: boolean
app: {
service_name: string
friendly_name: string | null
image: string
category: string
icon: string
ports: Array<{ container: number; host: number }>
volumes: Array<{ host_path: string; container_path: string }>
env: string[]
memory_mb?: number
cpus?: number
}
}>(`/system/services/custom/${service_name}`)
return response.data
})()
}
async updateCustomApp(payload: {
service_name: string
friendly_name: string
image: string
ports?: Array<{ container: number; host: number }>
volumes?: Array<{ host_path: string; container_path: string }>
env?: string[]
category?: string
icon?: string
memory_mb?: number
cpus?: number
force?: boolean
}) {
return catchInternal(async () => {
const response = await this.client.put<{
success: boolean
message: string
service_name: string
}>('/system/services/custom', payload)
return response.data
})()
}
}
export default new API()

View File

@ -1,35 +1,61 @@
import {
IconAntenna,
IconArrowUp,
IconBook,
IconBooks,
IconBox,
IconBrain,
IconBrandDocker,
IconBroadcast,
IconChefHat,
IconCheck,
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconCircleCheck,
IconCloudDownload,
IconCloudUpload,
IconCode,
IconCopy,
IconCpu,
IconDatabase,
IconDownload,
IconExternalLink,
IconFileDescription,
IconFolderOpen,
IconHome,
IconInfoCircle,
IconLogs,
IconMap,
IconMenu2,
IconMoon,
IconMovie,
IconNotes,
IconPencil,
IconPlant,
IconPlayerPlay,
IconPlayerStop,
IconPlus,
IconRefresh,
IconRefreshAlert,
IconRobot,
IconSchool,
IconSearch,
IconServer,
IconSettings,
IconShieldCheck,
IconShieldLock,
IconStethoscope,
IconSun,
IconTool,
IconTrash,
IconUpload,
IconWand,
IconWifi,
IconWorld,
IconX,
IconAlertTriangle,
IconXboxX,
IconCircleCheck,
IconInfoCircle,
IconBug,
IconCopy,
IconLibrary,
@ -37,14 +63,6 @@ import {
IconMenu2,
IconArrowLeft,
IconArrowRight,
IconSun,
IconMoon,
IconStethoscope,
IconShieldCheck,
IconTool,
IconPlant,
IconCode,
IconMap,
} from '@tabler/icons-react'
/**
@ -55,15 +73,21 @@ import {
* very limited subset of the full Tabler Icons library.
*/
export const icons = {
IconAntenna,
IconAlertTriangle,
IconArrowLeft,
IconArrowRight,
IconArrowUp,
IconBook,
IconBooks,
IconBox,
IconBrain,
IconBrandDocker,
IconBroadcast,
IconBug,
IconChefHat,
IconCheck,
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconCircleCheck,
@ -74,6 +98,9 @@ export const icons = {
IconCpu,
IconDatabase,
IconDownload,
IconExternalLink,
IconFileDescription,
IconFolderOpen,
IconHome,
IconInfoCircle,
IconLibrary,
@ -81,23 +108,29 @@ export const icons = {
IconMap,
IconMenu2,
IconMoon,
IconMovie,
IconNotes,
IconPencil,
IconPlant,
IconPlayerPlay,
IconPlayerStop,
IconPlus,
IconRefresh,
IconRefreshAlert,
IconRobot,
IconSchool,
IconSearch,
IconServer,
IconSettings,
IconShieldCheck,
IconShieldLock,
IconStethoscope,
IconSun,
IconTool,
IconTrash,
IconUpload,
IconWand,
IconWifi,
IconWorld,
IconX,
IconXboxX

View File

@ -52,19 +52,62 @@ 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 by bucket then
* alphabetically by filename so related items cluster naturally.
* - 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.
*/
export function groupAndSortKbFiles(files: StoredFileInfo[]): KbFileGroup[] {
export function groupAndSortKbFiles(
files: StoredFileInfo[],
sort: KbFileSort = DEFAULT_SORT
): KbFileGroup[] {
const buckets: Record<KbFileBucket, StoredFileInfo[]> = {
zim: [],
upload: [],
@ -90,13 +133,14 @@ export function groupAndSortKbFiles(files: StoredFileInfo[]): KbFileGroup[] {
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) =>
sourceToDisplayName(a.source).localeCompare(sourceToDisplayName(b.source))
)) {
for (const file of members.sort((a, b) => compareForSort(a, b, sort))) {
groups.push({
bucket,
source: file.source,
@ -105,6 +149,9 @@ export function groupAndSortKbFiles(files: StoredFileInfo[]): KbFileGroup[] {
members: [],
state: file.state,
chunksEmbedded: file.chunksEmbedded,
size: file.size,
uploadedAt: file.uploadedAt,
isUserUpload: file.isUserUpload,
})
}
}

View File

@ -1,6 +1,39 @@
/**
* Normalize a user-supplied custom app URL. Accepts a bare host ("jellyfin.lan",
* "10.0.0.5:8096") or a full URL; when no scheme is present, http:// is prepended
* (LAN resources are usually plain HTTP). Returns the normalized href, or null when
* the input is empty (clears the override) or not a valid http(s) URL. Restricting to
* http/https is the XSS guard javascript:/data: URLs never make it into an href.
*/
export function normalizeCustomUrl(input: string | null | undefined): string | null {
const trimmed = (input ?? '').trim();
if (!trimmed) return null;
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
try {
const url = new URL(withScheme);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
return url.href;
} catch {
return null;
}
}
export function getServiceLink(ui_location: string, customUrl?: string | null): string {
// A user-set custom URL (reverse proxy / local DNS) overrides the computed default. Only
// accepted when it normalizes to a valid http(s) URL — otherwise fall through to the default.
const normalizedCustom = normalizeCustomUrl(customUrl);
if (normalizedCustom) {
return normalizedCustom;
}
// "https:8480" / "http:8480" — an explicit scheme + port served on the current host. Checked
// before the URL parse below because new URL("https:8480") would mis-parse 8480 as the host.
const schemePort = ui_location.match(/^(https?):(\d+)$/);
if (schemePort) {
return `${schemePort[1]}://${window.location.hostname}:${schemePort[2]}`;
}
export function getServiceLink(ui_location: string): string {
// Check if the ui location is a valid URL
try {
const url = new URL(ui_location);

View File

@ -1,8 +1,22 @@
import { Head } from '@inertiajs/react'
import { useEffect } from 'react'
import MarkdocRenderer from '~/components/MarkdocRenderer'
import DocsLayout from '~/layouts/DocsLayout'
export default function Show({ content }: { content: any; }) {
// Deep-link support: when arriving at /docs/<slug>#<anchor> (e.g. from a Supply Depot app's
// "Docs" menu item), scroll to that section. The content renders synchronously from props, so a
// double rAF lets the headings paint before we look one up.
useEffect(() => {
const hash = decodeURIComponent(window.location.hash.replace(/^#/, ''))
if (!hash) return
requestAnimationFrame(() =>
requestAnimationFrame(() => {
document.getElementById(hash)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
)
}, [content])
return (
<DocsLayout>
<Head title={'Documentation'} />

Some files were not shown because too many files have changed in this diff Show More