mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-04-04 07:46:16 +02:00
Compare commits
130 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dcbf7dbcf | ||
|
|
37abad33c9 | ||
|
|
d666b24598 | ||
|
|
a813468949 | ||
|
|
e72268bb1c | ||
|
|
0183b42d71 | ||
|
|
6287755946 | ||
|
|
0f9be7c215 | ||
|
|
afbe4c42b1 | ||
|
|
d7e3d9246b | ||
|
|
cb4fa003a4 | ||
|
|
877fb1276a | ||
|
|
1e4b7aea82 | ||
|
|
a14dd688fa | ||
|
|
1bd1811498 | ||
|
|
3e922877d2 | ||
|
|
9964a82240 | ||
|
|
91a0b8bad5 | ||
|
|
9e3828bcba | ||
|
|
43c8876f19 | ||
|
|
31986d7319 | ||
|
|
0edfdead90 | ||
|
|
2e3253b1ac | ||
|
|
a6c257ab27 | ||
|
|
f4beb9a18a | ||
|
|
bac53e28dc | ||
|
|
2609530d25 | ||
|
|
b65b6d6b35 | ||
|
|
7711b5f0e8 | ||
|
|
e9af7a555b | ||
|
|
b183bc6745 | ||
|
|
fc6152c908 | ||
|
|
6a0195b9fc | ||
|
|
789fdfe95d | ||
|
|
1def8c0991 | ||
|
|
9ba1bbf715 | ||
|
|
328453c4cf | ||
|
|
ed8918f2e9 | ||
|
|
d474c142a1 | ||
|
|
32f8b0ff98 | ||
|
|
69c15b8b1e | ||
|
|
d25292a713 | ||
|
|
f32ba3bb51 | ||
|
|
44ecf41ca6 | ||
|
|
5c92c89813 | ||
|
|
f9e3773ec3 | ||
|
|
e5a7edca03 | ||
|
|
bd015f4c56 | ||
|
|
0e60e246e1 | ||
|
|
c67653b87a | ||
|
|
643eaea84b | ||
|
|
150134a9fa | ||
|
|
6b558531be | ||
|
|
4642dee6ce | ||
|
|
78c0b1d24d | ||
|
|
0226e651c7 | ||
|
|
7ab5e65826 | ||
|
|
b7ed8b6694 | ||
|
|
4443799cc9 | ||
|
|
4219e753da | ||
|
|
f00bfff77c | ||
|
|
5e93f2661b | ||
|
|
9a8378d63a | ||
|
|
982dceb949 | ||
|
|
6a1d0e83f9 | ||
|
|
edcfd937e2 | ||
|
|
f9062616b8 | ||
|
|
efe6af9b24 | ||
|
|
8b96793c4d | ||
|
|
735b9e8ae6 | ||
|
|
c409896718 | ||
|
|
f004c002a7 | ||
|
|
d501d2dc7e | ||
|
|
8e84ece2ef | ||
|
|
a4de8d05f7 | ||
|
|
28df8e6b23 | ||
|
|
baeb96b863 | ||
|
|
d645fc161b | ||
|
|
b8cf1b6127 | ||
|
|
f5a181b09f | ||
|
|
4784cd6e43 | ||
|
|
467299b231 | ||
|
|
5dfa6d7810 | ||
|
|
571f6bb5a2 | ||
|
|
023e3f30af | ||
|
|
d6c6cb66fa | ||
|
|
b8d36da9e1 | ||
|
|
6b41ebbd45 | ||
|
|
85492454a5 | ||
|
|
77e83085d6 | ||
|
|
0ec5334e0d | ||
|
|
6cb2a0d944 | ||
|
|
6934e8b4d1 | ||
|
|
bb0c4d19d8 | ||
|
|
1c179efde2 | ||
|
|
5dc48477f6 | ||
|
|
b0b8f07661 | ||
|
|
5e290119ab | ||
|
|
ab5a7cb178 | ||
|
|
5b990b7323 | ||
|
|
92ce7400e7 | ||
|
|
d53ccd2dc8 | ||
|
|
c0b1980bbc | ||
|
|
9b74c71f29 | ||
|
|
9802dd7c70 | ||
|
|
138ad84286 | ||
|
|
34076b107b | ||
|
|
5e0fba29ca | ||
|
|
06e1c4f4f2 | ||
|
|
fbc48dd115 | ||
|
|
e4fde22dd9 | ||
|
|
826c819b4a | ||
|
|
fe0c2afe60 | ||
|
|
9220b4b83d | ||
|
|
6120e257e8 | ||
|
|
bd642ac1e8 | ||
|
|
6a737ed83f | ||
|
|
b1edef27e8 | ||
|
|
ed0b0f76ec | ||
|
|
b40d8190af | ||
|
|
8bb8b414f8 | ||
|
|
fb05ab53e2 | ||
|
|
a4e6a9bd9f | ||
|
|
5113cc3eed | ||
|
|
86575bfc73 | ||
|
|
baf16ae824 | ||
|
|
db22b0c5f6 | ||
|
|
5d97d471d0 | ||
|
|
84aa125c0f | ||
|
|
0f8a391e39 |
193
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
193
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
name: Bug Report
|
||||
description: Report a bug or issue with Project N.O.M.A.D.
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "needs-triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug! Please fill out the information below to help us diagnose and fix the issue.
|
||||
|
||||
**Before submitting:**
|
||||
- Search existing issues to avoid duplicates
|
||||
- Ensure you're running the latest version of N.O.M.A.D.
|
||||
- Redact any personal or sensitive information from logs/configs
|
||||
- Please don't submit issues related to running N.O.M.A.D. on Unraid or another NAS - we don't have plans to support these kinds of platforms at this time
|
||||
|
||||
- type: dropdown
|
||||
id: issue-category
|
||||
attributes:
|
||||
label: Issue Category
|
||||
description: What area is this issue related to?
|
||||
options:
|
||||
- Installation/Setup
|
||||
- AI Assistant (Ollama)
|
||||
- Knowledge Base/RAG (Document Upload)
|
||||
- Docker/Container Issues
|
||||
- GPU Configuration
|
||||
- Content Downloads (ZIM, Maps, Collections)
|
||||
- Service Management (Start/Stop/Update)
|
||||
- System Performance/Resources
|
||||
- UI/Frontend Issue
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Bug Description
|
||||
description: Provide a clear and concise description of what the bug is
|
||||
placeholder: What happened? What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: How can we reproduce this issue?
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What did you expect to happen?
|
||||
placeholder: Describe the expected outcome
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual-behavior
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: What actually happened?
|
||||
placeholder: Describe what actually occurred, including any error messages
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: nomad-version
|
||||
attributes:
|
||||
label: N.O.M.A.D. Version
|
||||
description: What version of N.O.M.A.D. are you running? (Check Settings > Update or run `docker ps` and check nomad_admin image tag)
|
||||
placeholder: "e.g., 1.29.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating System
|
||||
description: What OS are you running N.O.M.A.D. on?
|
||||
options:
|
||||
- Ubuntu 24.04
|
||||
- Ubuntu 22.04
|
||||
- Ubuntu 20.04
|
||||
- Debian 13 (Trixie)
|
||||
- Debian 12 (Bookworm)
|
||||
- Debian 11 (Bullseye)
|
||||
- Other Debian-based
|
||||
- Other (not yet officially supported)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: docker-version
|
||||
attributes:
|
||||
label: Docker Version
|
||||
description: What version of Docker are you running? (`docker --version`)
|
||||
placeholder: "e.g., Docker version 24.0.7"
|
||||
|
||||
- type: dropdown
|
||||
id: gpu-present
|
||||
attributes:
|
||||
label: Do you have a dedicated GPU?
|
||||
options:
|
||||
- "Yes"
|
||||
- "No"
|
||||
- "Not sure"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: gpu-model
|
||||
attributes:
|
||||
label: GPU Model (if applicable)
|
||||
description: What GPU model do you have? (Check Settings > System or run `nvidia-smi` if NVIDIA GPU)
|
||||
placeholder: "e.g., NVIDIA GeForce RTX 3060"
|
||||
|
||||
- type: textarea
|
||||
id: system-specs
|
||||
attributes:
|
||||
label: System Specifications
|
||||
description: Provide relevant system specs (CPU, RAM, available disk space)
|
||||
placeholder: |
|
||||
CPU:
|
||||
RAM:
|
||||
Available Disk Space:
|
||||
GPU (if any):
|
||||
|
||||
- type: textarea
|
||||
id: service-status
|
||||
attributes:
|
||||
label: Service Status (if relevant)
|
||||
description: If this is a service-related issue, what's the status of relevant services? (Check Settings > Apps or run `docker ps`)
|
||||
placeholder: |
|
||||
Paste output from `docker ps` or describe service states from the UI
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant Logs
|
||||
description: |
|
||||
Include any relevant logs or error messages. **Please redact any personal/sensitive information.**
|
||||
|
||||
Useful commands for collecting logs:
|
||||
- N.O.M.A.D. management app: `docker logs nomad_admin`
|
||||
- Ollama: `docker logs nomad_ollama`
|
||||
- Qdrant: `docker logs nomad_qdrant`
|
||||
- Specific service: `docker logs nomad_<service-name>`
|
||||
placeholder: Paste relevant log output here
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: browser-console
|
||||
attributes:
|
||||
label: Browser Console Errors (if UI issue)
|
||||
description: If this is a UI issue, include any errors from your browser's developer console (F12)
|
||||
placeholder: Paste browser console errors here
|
||||
render: javascript
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: If applicable, add screenshots to help explain your problem (drag and drop images here)
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context about the problem here (network setup, custom configurations, recent changes, etc.)
|
||||
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Pre-submission Checklist
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I have searched for existing issues that might be related to this bug
|
||||
required: true
|
||||
- label: I am running the latest version of Project N.O.M.A.D. (or have noted my version above)
|
||||
required: true
|
||||
- label: I have redacted any personal or sensitive information from logs and screenshots
|
||||
required: true
|
||||
- label: This issue is NOT related to running N.O.M.A.D. on an unsupported/non-Debian-based OS
|
||||
required: false
|
||||
17
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
17
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 💬 Discord Community
|
||||
url: https://discord.com/invite/crosstalksolutions
|
||||
about: Join our Discord community for general questions, support, and discussions
|
||||
- name: 📖 Documentation
|
||||
url: https://projectnomad.us
|
||||
about: Check the official documentation and guides
|
||||
- name: 🏆 Community Leaderboard
|
||||
url: https://benchmark.projectnomad.us
|
||||
about: View the N.O.M.A.D. benchmark leaderboard
|
||||
- name: 🤝 Contributing Guide
|
||||
url: https://github.com/Crosstalk-Solutions/project-nomad/blob/main/CONTRIBUTING.md
|
||||
about: Learn how to contribute to Project N.O.M.A.D.
|
||||
- name: 📅 Roadmap
|
||||
url: https://roadmap.projectnomad.us
|
||||
about: See our public roadmap, vote on features, and suggest new ones
|
||||
150
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
150
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
name: Feature Request
|
||||
description: Suggest a new feature or enhancement for Project N.O.M.A.D.
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement", "needs-discussion"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for your interest in improving Project N.O.M.A.D.! Before you submit a feature request, consider checking our [roadmap](https://roadmap.projectnomad.us) to see if it's already planned or in progress. You're welcome to suggest new ideas there if you don't plan on opening PRs yourself.
|
||||
|
||||
|
||||
**Please note:** Feature requests are not guaranteed to be implemented. All requests are evaluated based on alignment with the project's goals, feasibility, and community demand.
|
||||
|
||||
**Before submitting:**
|
||||
- Search existing feature requests and our [roadmap](https://roadmap.projectnomad.us) to avoid duplicates
|
||||
- Consider if this aligns with N.O.M.A.D.'s mission: offline-first knowledge and education
|
||||
- Consider the technical feasibility of the feature: N.O.M.A.D. is designed to be containerized and run on a wide range of hardware, so features that require heavy resources (aside from GPU-intensive tasks) or complex host configurations may be less likely to be implemented
|
||||
- Consider the scope of the feature: Small, focused enhancements that can be implemented incrementally are more likely to be implemented than large, broad features that would require significant development effort or have an unclear path forward
|
||||
- If you're able to contribute code, testing, or documentation, that significantly increases the chances of your feature being implemented
|
||||
|
||||
- type: dropdown
|
||||
id: feature-category
|
||||
attributes:
|
||||
label: Feature Category
|
||||
description: What area does this feature relate to?
|
||||
options:
|
||||
- New Service/Tool Integration
|
||||
- AI Assistant Enhancement
|
||||
- Knowledge Base/RAG Improvement
|
||||
- Content Management (ZIM, Maps, Collections)
|
||||
- UI/UX Improvement
|
||||
- System Management
|
||||
- Performance Optimization
|
||||
- Documentation
|
||||
- Security
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem Statement
|
||||
description: What problem does this feature solve? Is your feature request related to a pain point?
|
||||
placeholder: I find it frustrating when... / It would be helpful if... / Users struggle with...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe the feature or enhancement you'd like to see
|
||||
placeholder: Add a feature that... / Change the behavior to... / Integrate with...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternative Solutions
|
||||
description: Have you considered any alternative solutions or workarounds?
|
||||
placeholder: I've tried... / Another approach could be... / A workaround is...
|
||||
|
||||
- type: textarea
|
||||
id: use-case
|
||||
attributes:
|
||||
label: Use Case
|
||||
description: Describe a specific scenario where this feature would be valuable
|
||||
placeholder: |
|
||||
As a [type of user], when I [do something], I want to [accomplish something] so that [benefit].
|
||||
|
||||
Example: Because I have a dedicated GPU, I want to be able to see in the UI if GPU support is enabled so that I can optimize performance and troubleshoot issues more easily.
|
||||
|
||||
- type: dropdown
|
||||
id: user-type
|
||||
attributes:
|
||||
label: Who would benefit from this feature?
|
||||
description: What type of users would find this most valuable?
|
||||
multiple: true
|
||||
options:
|
||||
- Individual/Home Users
|
||||
- Families
|
||||
- Teachers/Educators
|
||||
- Students
|
||||
- Survivalists/Preppers
|
||||
- Developers/Contributors
|
||||
- Organizations
|
||||
- All Users
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: How important is this feature to you?
|
||||
options:
|
||||
- Critical - Blocking my use of N.O.M.A.D.
|
||||
- High - Would significantly improve my experience
|
||||
- Medium - Would be nice to have
|
||||
- Low - Minor convenience
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: implementation-ideas
|
||||
attributes:
|
||||
label: Implementation Ideas (Optional)
|
||||
description: If you have technical suggestions for how this could be implemented, share them here
|
||||
placeholder: This could potentially use... / It might integrate with... / A possible approach is...
|
||||
|
||||
- type: textarea
|
||||
id: examples
|
||||
attributes:
|
||||
label: Examples or References
|
||||
description: Are there similar features in other applications? Include links, screenshots, or descriptions
|
||||
placeholder: Similar to how [app name] does... / See this example at [URL]
|
||||
|
||||
- type: dropdown
|
||||
id: willing-to-contribute
|
||||
attributes:
|
||||
label: Would you be willing to help implement this?
|
||||
description: Contributing increases the likelihood of implementation
|
||||
options:
|
||||
- "Yes - I can write the code"
|
||||
- "Yes - I can help test"
|
||||
- "Yes - I can help with documentation"
|
||||
- "Maybe - with guidance"
|
||||
- "No - I don't have the skills/time"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, mockups, diagrams, or information about the feature request
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Pre-submission Checklist
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I have searched for existing feature requests that might be similar
|
||||
required: true
|
||||
- label: This feature aligns with N.O.M.A.D.'s mission of offline-first knowledge and education
|
||||
required: true
|
||||
- label: I understand that feature requests are not guaranteed to be implemented
|
||||
required: true
|
||||
25
.github/workflows/build-admin-on-pr.yml
vendored
Normal file
25
.github/workflows/build-admin-on-pr.yml
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
name: Build Admin
|
||||
|
||||
on: pull_request
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: ./admin
|
||||
|
||||
- name: Run build
|
||||
run: npm run build
|
||||
working-directory: ./admin
|
||||
51
.github/workflows/build-disk-collector.yml
vendored
Normal file
51
.github/workflows/build-disk-collector.yml
vendored
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
name: Build Disk Collector Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Semantic version to label the Docker image under (no "v" prefix, e.g. "1.2.3")'
|
||||
required: true
|
||||
type: string
|
||||
tag_latest:
|
||||
description: 'Also tag this image as :latest?'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
check_authorization:
|
||||
name: Check authorization to publish new Docker image
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
isAuthorized: ${{ steps.check-auth.outputs.is_authorized }}
|
||||
steps:
|
||||
- name: check-auth
|
||||
id: check-auth
|
||||
run: echo "is_authorized=${{ contains(secrets.DEPLOYMENT_AUTHORIZED_USERS, github.triggering_actor) }}" >> $GITHUB_OUTPUT
|
||||
build:
|
||||
name: Build disk-collector image
|
||||
needs: check_authorization
|
||||
if: needs.check_authorization.outputs.isAuthorized == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: install/sidecar-disk-collector
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/crosstalk-solutions/project-nomad-disk-collector:${{ inputs.version }}
|
||||
ghcr.io/crosstalk-solutions/project-nomad-disk-collector:v${{ inputs.version }}
|
||||
${{ inputs.tag_latest && 'ghcr.io/crosstalk-solutions/project-nomad-disk-collector:latest' || '' }}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
name: Build Docker Image
|
||||
name: Build Primary Docker Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
|
@ -33,15 +33,15 @@ jobs:
|
|||
packages: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
push: true
|
||||
tags: |
|
||||
51
.github/workflows/build-sidecar-updater.yml
vendored
Normal file
51
.github/workflows/build-sidecar-updater.yml
vendored
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
name: Build Sidecar Updater Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Semantic version to label the Docker image under (no "v" prefix, e.g. "1.2.3")'
|
||||
required: true
|
||||
type: string
|
||||
tag_latest:
|
||||
description: 'Also tag this image as :latest?'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
check_authorization:
|
||||
name: Check authorization to publish new Docker image
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
isAuthorized: ${{ steps.check-auth.outputs.is_authorized }}
|
||||
steps:
|
||||
- name: check-auth
|
||||
id: check-auth
|
||||
run: echo "is_authorized=${{ contains(secrets.DEPLOYMENT_AUTHORIZED_USERS, github.triggering_actor) }}" >> $GITHUB_OUTPUT
|
||||
build:
|
||||
name: Build sidecar-updater image
|
||||
needs: check_authorization
|
||||
if: needs.check_authorization.outputs.isAuthorized == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: install/sidecar-updater
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/crosstalk-solutions/project-nomad-sidecar-updater:${{ inputs.version }}
|
||||
ghcr.io/crosstalk-solutions/project-nomad-sidecar-updater:v${{ inputs.version }}
|
||||
${{ inputs.tag_latest && 'ghcr.io/crosstalk-solutions/project-nomad-sidecar-updater:latest' || '' }}
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
|
|
@ -22,12 +22,12 @@ jobs:
|
|||
newVersion: ${{ steps.semver.outputs.new_release_version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: semantic-release
|
||||
uses: cycjimmy/semantic-release-action@v3
|
||||
uses: cycjimmy/semantic-release-action@v6
|
||||
id: semver
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.COSMISTACKBOT_ACCESS_TOKEN }}
|
||||
|
|
|
|||
58
.github/workflows/validate-collection-urls.yml
vendored
Normal file
58
.github/workflows/validate-collection-urls.yml
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
name: Validate Collection URLs
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'collections/**.json'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'collections/**.json'
|
||||
|
||||
jobs:
|
||||
validate-urls:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Extract and validate URLs
|
||||
run: |
|
||||
FAILED=0
|
||||
CHECKED=0
|
||||
FAILED_URLS=""
|
||||
|
||||
# Recursively extract all non-null string URLs from every JSON file in collections/
|
||||
URLS=$(jq -r '.. | .url? | select(type == "string")' collections/*.json | sort -u)
|
||||
|
||||
while IFS= read -r url; do
|
||||
[ -z "$url" ] && continue
|
||||
CHECKED=$((CHECKED + 1))
|
||||
printf "Checking: %s ... " "$url"
|
||||
|
||||
# Use Range: bytes=0-0 to avoid downloading the full file.
|
||||
# --max-filesize 1 aborts early if the server ignores the Range header
|
||||
# and returns 200 with the full body. The HTTP status is still captured.
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
--range 0-0 \
|
||||
--max-filesize 1 \
|
||||
--max-time 30 \
|
||||
--location \
|
||||
"$url")
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "206" ]; then
|
||||
echo "OK ($HTTP_CODE)"
|
||||
else
|
||||
echo "FAILED ($HTTP_CODE)"
|
||||
FAILED=$((FAILED + 1))
|
||||
FAILED_URLS="$FAILED_URLS\n - $url (HTTP $HTTP_CODE)"
|
||||
fi
|
||||
done <<< "$URLS"
|
||||
|
||||
echo ""
|
||||
echo "Checked $CHECKED URLs, $FAILED failed."
|
||||
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "Broken URLs:"
|
||||
printf "%b\n" "$FAILED_URLS"
|
||||
exit 1
|
||||
fi
|
||||
128
CODE_OF_CONDUCT.md
Normal file
128
CODE_OF_CONDUCT.md
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
168
CONTRIBUTING.md
Normal file
168
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
# Contributing to Project N.O.M.A.D.
|
||||
|
||||
Thank you for your interest in contributing to Project N.O.M.A.D.! Community contributions are what keep this project growing and improving. Please read this guide fully before getting started — it will save you (and the maintainers) a lot of time.
|
||||
|
||||
> **Note:** Acceptance of contributions is not guaranteed. All pull requests are evaluated based on quality, relevance, and alignment with the project's goals. The maintainers of Project N.O.M.A.D. ("Nomad") reserve the right accept, deny, or modify any pull request at their sole discretion.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [Before You Start](#before-you-start)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Development Workflow](#development-workflow)
|
||||
- [Commit Messages](#commit-messages)
|
||||
- [Release Notes](#release-notes)
|
||||
- [Versioning](#versioning)
|
||||
- [Submitting a Pull Request](#submitting-a-pull-request)
|
||||
- [Feedback & Community](#feedback--community)
|
||||
|
||||
---
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
Please read and review our full [Code of Conduct](https://github.com/Crosstalk-Solutions/project-nomad/blob/main/CODE_OF_CONDUCT.md) before contributing. In short: please be respectful and considerate in all interactions with maintainers and other contributors.
|
||||
|
||||
We are committed to providing a welcoming environment for everyone. Disrespectful or abusive behavior will not be tolerated.
|
||||
|
||||
---
|
||||
|
||||
## Before You Start
|
||||
|
||||
**Open an issue first.** Before writing any code, please [open an issue](../../issues/new) to discuss your proposed change. This helps avoid duplicate work and ensures your contribution aligns with the project's direction.
|
||||
|
||||
When opening an issue:
|
||||
- Use a clear, descriptive title
|
||||
- Describe the problem you're solving or the feature you want to add
|
||||
- If it's a bug, include steps to reproduce it and as much detail about your environment as possible
|
||||
- Ensure you redact any personal or sensitive information in any logs, configs, etc.
|
||||
|
||||
---
|
||||
|
||||
## Getting Started with Contributing
|
||||
**Please note**: this is the Getting Started guide for developing and contributing to Nomad, NOT [installing Nomad](https://github.com/Crosstalk-Solutions/project-nomad/blob/main/README.md) for regular use!
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- A Debian-based OS (Ubuntu recommended)
|
||||
- `sudo`/root privileges
|
||||
- Docker installed and running
|
||||
- A stable internet connection (required for dependency downloads)
|
||||
- Node.js (for frontend/admin work)
|
||||
|
||||
### Fork & Clone
|
||||
|
||||
1. Click **Fork** at the top right of this repository
|
||||
2. Clone your fork locally:
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/project-nomad.git
|
||||
cd project-nomad
|
||||
```
|
||||
3. Add the upstream remote so you can stay in sync:
|
||||
```bash
|
||||
git remote add upstream https://github.com/Crosstalk-Solutions/project-nomad.git
|
||||
```
|
||||
|
||||
### Avoid Installing a Release Version Locally
|
||||
Because Nomad relies heavily on Docker, we actually recommend against installing a release version of the project on the same local machine where you are developing. This can lead to conflicts with ports, volumes, and other resources. Instead, you can run your development version in a separate Docker environment while keeping your local machine clean. It certainly __can__ be done, but it adds complexity to your setup and workflow. If you choose to install a release version locally, please ensure you have a clear strategy for managing potential conflicts and resource usage.
|
||||
|
||||
---
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **Sync with upstream** before starting any new work. We prefer rebasing over merge commits to keep a clean, linear git history as much as possible (this also makes it easier for maintainers to review and merge your changes). To sync with upstream:
|
||||
```bash
|
||||
git fetch upstream
|
||||
git checkout dev
|
||||
git rebase upstream/dev
|
||||
```
|
||||
|
||||
2. **Create a feature branch** off `dev` with a descriptive name:
|
||||
```bash
|
||||
git checkout -b fix/issue-123
|
||||
# or
|
||||
git checkout -b feature/add-new-tool
|
||||
```
|
||||
|
||||
3. **Make your changes.** Follow existing code style and conventions. Test your changes locally against a running N.O.M.A.D. instance before submitting.
|
||||
|
||||
4. **Add release notes** (see [Release Notes](#release-notes) below).
|
||||
|
||||
5. **Commit your changes** using [Conventional Commits](#commit-messages).
|
||||
|
||||
6. **Push your branch** and open a pull request.
|
||||
|
||||
---
|
||||
|
||||
## Commit Messages
|
||||
|
||||
This project uses [Conventional Commits](https://www.conventionalcommits.org/). All commit messages must follow this format:
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
```
|
||||
|
||||
**Common types:**
|
||||
|
||||
| Type | When to use |
|
||||
|------|-------------|
|
||||
| `feat` | A new user-facing feature |
|
||||
| `fix` | A bug fix |
|
||||
| `docs` | Documentation changes only |
|
||||
| `refactor` | Code change that isn't a fix or feature and does not affect functionality |
|
||||
| `chore` | Build process, dependency updates, tooling |
|
||||
| `test` | Adding or updating tests |
|
||||
|
||||
**Scope** is optional but encouraged — use it to indicate the area of the codebase affected (e.g., `api`, `ui`, `maps`).
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
feat(ui): add dark mode toggle to Command Center
|
||||
fix(api): resolve container status not updating after restart
|
||||
docs: update hardware requirements in README
|
||||
chore(deps): bump docker-compose to v2.24
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Release Notes
|
||||
|
||||
Human-readable release notes live in [`admin/docs/release-notes.md`](admin/docs/release-notes.md) and are displayed directly in the Command Center UI.
|
||||
|
||||
If your PR is merged in, the maintainers will update the release notes with a summary of your contribution and credit you as the author. You do not need to add this yourself in the PR (please don't, as it may cause merge conflicts), but you can include a suggested note in the PR description if you like.
|
||||
|
||||
---
|
||||
|
||||
## Versioning
|
||||
|
||||
This project uses [Semantic Versioning](https://semver.org/). Versions are managed in the root `package.json` and updated automatically by `semantic-release`. The `project-nomad` Docker image uses this version. The `admin/package.json` version stays at `0.0.0` and should not be changed manually.
|
||||
|
||||
---
|
||||
|
||||
## Submitting a Pull Request
|
||||
|
||||
1. Push your branch to your fork:
|
||||
```bash
|
||||
git push origin your-branch-name
|
||||
```
|
||||
2. Open a pull request against the `dev` branch of this repository
|
||||
3. In the PR description:
|
||||
- Summarize what your changes do and why
|
||||
- Reference the related issue (e.g., `Closes #123`)
|
||||
- Note any relevant testing steps or environment details
|
||||
4. Be responsive to feedback — maintainers may request changes. Pull requests with no activity for an extended period may be closed.
|
||||
|
||||
---
|
||||
|
||||
## Feedback & Community
|
||||
|
||||
Have questions or want to discuss ideas before opening an issue? Join the community:
|
||||
|
||||
- **Discord:** [Join the Crosstalk Solutions server](https://discord.com/invite/crosstalksolutions) — the best place to get help, share your builds, and talk with other N.O.M.A.D. users
|
||||
- **Website:** [www.projectnomad.us](https://www.projectnomad.us)
|
||||
- **Benchmark Leaderboard:** [benchmark.projectnomad.us](https://benchmark.projectnomad.us)
|
||||
|
||||
---
|
||||
|
||||
*Project N.O.M.A.D. is licensed under the [Apache License 2.0](LICENSE).*
|
||||
|
|
@ -45,7 +45,14 @@ COPY --from=production-deps /app/node_modules /app/node_modules
|
|||
COPY --from=build /app/build /app
|
||||
# Copy root package.json for version info
|
||||
COPY package.json /app/version.json
|
||||
|
||||
# Copy docs and README for access within the container
|
||||
COPY admin/docs /app/docs
|
||||
COPY README.md /app/README.md
|
||||
|
||||
# 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
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["node", "./bin/server.js"]
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
106
FAQ.md
Normal file
106
FAQ.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# Frequently Asked Questions (FAQ)
|
||||
|
||||
Find answers to some of the most common questions about Project N.O.M.A.D.
|
||||
|
||||
## Can I customize the port(s) that NOMAD uses?
|
||||
|
||||
Yes, you can customize the ports that NOMAD's core services (Command Center, MySQL, Redis) use. Please refer to the [Advanced Installation](README.md#advanced-installation) section of the README for more details on how to do this.
|
||||
|
||||
Note: As of 3/24/2026, only the core services defined in the `docker-compose.yml` file currently support port customization - the installable applications (e.g. Ollama, Kiwix, etc.) do not yet support this, but we have multiple PR's in the works to add this feature for all installable applications in a future release.
|
||||
|
||||
## Can I customize the storage location for NOMAD's data?
|
||||
|
||||
Yes, you can customize the storage location for NOMAD's content by modifying the `docker-compose.yml` file to adjust the appropriate bind mounts to point to your desired storage location on your host machine. Please refer to the [Advanced Installation](README.md#advanced-installation) section of the README for more details on how to do this.
|
||||
|
||||
## Can I store NOMAD's data on an external drive or network storage?
|
||||
|
||||
Short answer: yes, but we can't do it for you (and we recommend a local drive for best performance).
|
||||
|
||||
Long answer: Custom storage paths, mount points, and external drives (like iSCSI or SMB/NFS volumes) **are possible**, but this will be up to your individual configuration on the host before NOMAD starts, and then passed in via the compose.yml as this is a *host-level concern*, not a NOMAD-level concern (see above for details). NOMAD itself can't configure this for you, nor could we support all possible configurations in the install script.
|
||||
|
||||
## Can I run NOMAD on MAC, WSL2, or a non-Debian-based Distro?
|
||||
|
||||
See [Why does NOMAD require a Debian-based OS?](#why-does-nomad-require-a-debian-based-os)
|
||||
|
||||
## Why does NOMAD require a Debian-based OS?
|
||||
|
||||
Project N.O.M.A.D. is currently designed to run on Debian-based Linux distributions (with Ubuntu being the recommended distro) because our installation scripts and Docker configurations are optimized for this environment. While it's technically possible to run the Docker containers on other operating systems that support Docker, we have not tested or optimized the installation process for non-Debian-based systems, so we cannot guarantee a smooth experience on those platforms at this time.
|
||||
|
||||
Support for other operating systems will come in the future, but because our development resources are limited as a free and open-source project, we needed to prioritize our efforts and focus on a narrower set of supported platforms for the initial release. We chose Debian-based Linux as our starting point because it's widely used, easy to spin up, and provides a stable environment for running Docker containers.
|
||||
|
||||
Community members have provided guides for running N.O.M.A.D. on other platforms (e.g. WSL2, Mac, etc.) in our Discord community and [Github Discussions](https://github.com/Crosstalk-Solutions/project-nomad/discussions), so if you're interested in running N.O.M.A.D. on a non-Debian-based system, we recommend checking there for any available resources or guides. However, keep in mind that if you choose to run N.O.M.A.D. on a non-Debian-based system, you may encounter issues that we won't be able to provide support for, and you may need to have a higher level of technical expertise to troubleshoot and resolve any problems that arise.
|
||||
|
||||
## Can I run NOMAD on a Raspberry Pi or other ARM-based device?
|
||||
Project N.O.M.A.D. is currently designed to run on x86-64 architecture, and we have not yet tested or optimized it for ARM-based devices like the Raspberry Pi (and have not published any official images for ARM architecture).
|
||||
|
||||
Support for ARM-based devices is on our roadmap, but our initial focus was on x86-64 hardware due to its widespread use and compatibility with a wide range of applications.
|
||||
|
||||
Community members have forked and published their own ARM-compatible images and installation guides for running N.O.M.A.D. on Raspberry Pi and other ARM-based devices in our Discord community and [Github Discussions](https://github.com/Crosstalk-Solutions/project-nomad/discussions), but these are not officially supported by the core development team, and we cannot guarantee their functionality or provide support for any issues that arise when using these community-created resources.
|
||||
|
||||
## What are the hardware requirements for running NOMAD?
|
||||
|
||||
Project N.O.M.A.D. itself is quite lightweight and can run on even modest x86-64 hardware, but the tools and resources you choose to install with N.O.M.A.D. will determine the specs required for your unique deployment. Please see the [Hardware Guide](https://www.projectnomad.us/hardware) for detailed build recommendations at various price points.
|
||||
|
||||
## Does NOMAD support languages other than English?
|
||||
|
||||
As of March 2026, Project N.O.M.A.D.'s UI is only available in English, and the majority of the tools and resources available through N.O.M.A.D. are also primarily in English. However, we have multi-language support on our roadmap for a future release, and we are actively working on adding support for additional languages both in the UI and in the available tools/resources. If you're interested in contributing to this effort, please check out our [CONTRIBUTING.md](CONTRIBUTING.md) file for guidelines on how to get involved.
|
||||
|
||||
## What technologies is NOMAD built with?
|
||||
|
||||
Project N.O.M.A.D. is built using a combination of technologies, including:
|
||||
- **Docker:** for containerization of the Command Center and its dependencies
|
||||
- **Node.js & TypeScript:** for the backend of the Command Center, particularly the [AdonisJS](https://adonisjs.com/) framework
|
||||
- **React:** for the frontend of the Command Center, utilizing [Vite](https://vitejs.dev/) and [Inertia.js](https://inertiajs.com/) under the hood
|
||||
- **MySQL:** for the Command Center's database
|
||||
- **Redis:** for various caching, background jobs, "cron" tasks, and other internal processes within the Command Center
|
||||
|
||||
NOMAD makes use of the Docker-outside-of-Docker ("DooD") pattern, which allows the Command Center to manage and orchestrate other Docker containers on the host machine without needing to run Docker itself inside a container. This approach provides better performance and compatibility with a wider range of host environments while still allowing for powerful container management capabilities through the Command Center's UI.
|
||||
|
||||
## Can I run NOMAD if I have existing Docker containers on my machine?
|
||||
Yes, you can safely run Project N.O.M.A.D. on a machine that already has existing Docker containers. NOMAD is designed to coexist with other Docker containers and will not interfere with them as long as there are no port conflicts or resource constraints.
|
||||
|
||||
All of NOMAD's containers are prefixed with `nomad_` in their names, so they can be easily identified and managed separately from any other containers you may have running. Just make sure to review the ports that NOMAD's core services (Command Center, MySQL, Redis) use during installation and adjust them if necessary to avoid conflicts with your existing containers.
|
||||
|
||||
## Why does NOMAD require access to the Docker socket?
|
||||
|
||||
See [What technologies is NOMAD built with?](#what-technologies-is-nomad-built-with)
|
||||
|
||||
## Can I use any AI models?
|
||||
NOMAD by default uses Ollama inside of a docker container to run LLM Models for the AI Assistant. So if you find a model on HuggingFace for example, you won't be able to use that model in NOMAD. The list of available models in the AI Assistant settings (/settings/models) may not show all of the models you are looking for. If you found a model from https://ollama.com/search that you'd like to try and its not in the settings page, you can use a curl command to download the model.
|
||||
`curl -X POST -H "Content-Type: application/json" -d '{"model":"MODEL_NAME_HERE"}' http://localhost:8080/api/ollama/models` replacing MODEL_NAME_HERE with the model name from whats in the ollama website.
|
||||
|
||||
## Do I have to install the AI features in NOMAD?
|
||||
|
||||
No, the AI features in NOMAD (Ollama, Qdrant, custom RAG pipeline, etc.) are all optional and not required to use the core functionality of NOMAD.
|
||||
|
||||
## Is NOMAD actually free? Are there any hidden costs?
|
||||
Yes, Project N.O.M.A.D. is completely free and open-source software licensed under the Apache License 2.0. There are no hidden costs or fees associated with using NOMAD itself, and we don't have any plans to introduce "premium" features or paid tiers.
|
||||
|
||||
Aside from the cost of the hardware you choose to run it on, there are no costs associated with using NOMAD.
|
||||
|
||||
## Do you sell hardware or pre-built devices with NOMAD pre-installed?
|
||||
|
||||
No, we do not sell hardware or pre-built devices with NOMAD pre-installed at this time. Project N.O.M.A.D. is a free and open-source software project, and we provide detailed installation instructions and hardware recommendations for users to set up their own NOMAD instances on compatible hardware of their choice. The tradeoff to this DIY approach is some additional setup time and technical know-how required on the user's end, but it also allows for greater flexibility and customization in terms of hardware selection and configuration to best suit each user's unique needs, budget, and preferences.
|
||||
|
||||
## How quickly are issues resolved when reported?
|
||||
|
||||
We strive to address and resolve issues as quickly as possible, but please keep in mind that Project N.O.M.A.D. is a free and open-source project maintained by a small team of volunteers. We prioritize issues based on their severity, impact on users, and the resources required to resolve them. Critical issues that affect a large number of users are typically addressed more quickly, while less severe issues may take longer to resolve. Aside from the development efforts needed to address the issue, we do our best to conduct thorough testing and validation to ensure that any fix we implement doesn't introduce new issues or regressions, which also adds to the time it takes to resolve an issue.
|
||||
|
||||
We also encourage community involvement in troubleshooting and resolving issues, so if you encounter a problem, please consider checking our Discord community and Github Discussions for potential solutions or workarounds while we work on an official fix.
|
||||
|
||||
## How often are new features added or updates released?
|
||||
|
||||
We aim to release updates and new features on a regular basis, but the exact timing can vary based on the complexity of the features being developed, the resources available to our volunteer development team, and the feedback and needs of our community. We typically release smaller "patch" versions more frequently to address bugs and make minor improvements, while larger feature releases may take more time to develop and test before they're ready for release.
|
||||
|
||||
## I opened a PR to contribute a new feature or fix a bug. How long does it usually take for PRs to be reviewed and merged?
|
||||
We appreciate all contributions to the project and strive to review and merge pull requests (PRs) as quickly as possible. The time it takes for a PR to be reviewed and merged can vary based on several factors, including the complexity of the changes, the current workload of our maintainers, and the need for any additional testing or revisions.
|
||||
|
||||
Because NOMAD is still a young project, some PRs (particularly those for new features) may take longer to review and merge as we prioritize building out the core functionality and ensuring stability before adding new features. However, we do our best to provide timely feedback on all PRs and keep contributors informed about the status of their contributions.
|
||||
|
||||
## I have a question that isn't answered here. Where can I ask for help?
|
||||
|
||||
If you have a question that isn't answered in this FAQ, please feel free to ask for help in our Discord community (https://discord.com/invite/crosstalksolutions) or on our Github Discussions page (https://github.com/Crosstalk-Solutions/project-nomad/discussions).
|
||||
|
||||
## I have a suggestion for a new feature or improvement. How can I share it?
|
||||
|
||||
We welcome and encourage suggestions for new features and improvements! We highly encourage sharing your ideas (or upvoting existing suggestions) on our public roadmap at https://roadmap.projectnomad.us, where we track new feature requests. This is the best way to ensure that your suggestion is seen by the development team and the community, and it also allows other community members to upvote and show support for your idea, which can help prioritize it for future development.
|
||||
69
README.md
69
README.md
|
|
@ -1,5 +1,5 @@
|
|||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/admin/public/project_nomad_logo.png" width="200" height="200"/>
|
||||
<img src="admin/public/project_nomad_logo.webp" width="200" height="200"/>
|
||||
|
||||
# Project N.O.M.A.D.
|
||||
### Node for Offline Media, Archives, and Data
|
||||
|
|
@ -21,18 +21,27 @@ Project N.O.M.A.D. can be installed on any Debian-based operating system (we rec
|
|||
|
||||
*Note: sudo/root privileges are required to run the install script*
|
||||
|
||||
#### Quick Install
|
||||
### Quick Install (Debian-based OS Only)
|
||||
```bash
|
||||
sudo apt-get update && sudo apt-get install -y curl && curl -fsSL https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/install_nomad.sh -o install_nomad.sh && sudo bash install_nomad.sh
|
||||
sudo apt-get update && \
|
||||
sudo apt-get install -y curl && \
|
||||
curl -fsSL https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/install_nomad.sh \
|
||||
-o install_nomad.sh && \
|
||||
sudo bash install_nomad.sh
|
||||
```
|
||||
|
||||
Project N.O.M.A.D. is now installed on your device! Open a browser and navigate to `http://localhost:8080` (or `http://DEVICE_IP:8080`) to start exploring!
|
||||
|
||||
For a complete step-by-step walkthrough (including Ubuntu installation), see the [Installation Guide](https://www.projectnomad.us/install).
|
||||
|
||||
### Advanced Installation
|
||||
For more control over the installation process, copy and paste the [Docker Compose template](https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/management_compose.yaml) into a `docker-compose.yml` file and customize it to your liking (be sure to replace any placeholders with your actual values). Then, run `docker compose up -d` to start the Command Center and its dependencies. Note: this method is recommended for advanced users only, as it requires familiarity with Docker and manual configuration before starting.
|
||||
|
||||
## How It Works
|
||||
N.O.M.A.D. is a management UI ("Command Center") and API that orchestrates a collection of containerized tools and resources via [Docker](https://www.docker.com/). It handles installation, configuration, and updates for everything — so you don't have to.
|
||||
|
||||
**Built-in capabilities include:**
|
||||
- **AI Chat with Knowledge Base** — local AI chat powered by [Ollama](https://ollama.com/), with document upload and semantic search (RAG via [Qdrant](https://qdrant.tech/))
|
||||
- **AI Chat with Knowledge Base** — local AI chat powered by [Ollama](https://ollama.com/) or you can use OpenAI API compatible software such as LM Studio or llama.cpp, with document upload and semantic search (RAG via [Qdrant](https://qdrant.tech/))
|
||||
- **Information Library** — offline Wikipedia, medical references, ebooks, and more via [Kiwix](https://kiwix.org/)
|
||||
- **Education Platform** — Khan Academy courses with progress tracking via [Kolibri](https://learningequality.org/kolibri/)
|
||||
- **Offline Maps** — downloadable regional maps via [ProtoMaps](https://protomaps.com)
|
||||
|
|
@ -80,10 +89,19 @@ To run LLM's and other included AI tools:
|
|||
- OS: Debian-based (Ubuntu recommended)
|
||||
- Stable internet connection (required during install only)
|
||||
|
||||
**For detailed build recommendations at three price points ($200–$800+), see the [Hardware Guide](https://www.projectnomad.us/hardware).**
|
||||
**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
|
||||
|
||||
#### 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.
|
||||
You are responsible for the setup of Ollama/OpenAI server on the other host.
|
||||
|
||||
## Frequently Asked Questions (FAQ)
|
||||
For answers to common questions about Project N.O.M.A.D., please see our [FAQ](FAQ.md) page.
|
||||
|
||||
## About Internet Usage & Privacy
|
||||
Project N.O.M.A.D. is designed for offline usage. An internet connection is only required during the initial installation (to download dependencies) and if you (the user) decide to download additional tools and resources at a later time. Otherwise, N.O.M.A.D. does not require an internet connection and has ZERO built-in telemetry.
|
||||
|
||||
|
|
@ -92,49 +110,20 @@ To test internet connectivity, N.O.M.A.D. attempts to make a request to Cloudfla
|
|||
## About Security
|
||||
By design, Project N.O.M.A.D. is intended to be open and available without hurdles - it includes no authentication. If you decide to connect your device to a local network after install (e.g. for allowing other devices to access it's resources), you can block/open ports to control which services are exposed.
|
||||
|
||||
**Will authentication be added in the future?** Maybe. It's not currently a priority, but if there's enough demand for it, we may consider building in an optional authentication layer in a future release to support uses cases where multiple users need access to the same instance but with different permission levels (e.g. family use with parental controls, classroom use with teacher/admin accounts, etc.). For now, we recommend using network-level controls to manage access if you're planning to expose your N.O.M.A.D. instance to other devices on a local network. N.O.M.A.D. is not designed to be exposed directly to the internet, and we strongly advise against doing so unless you really know what you're doing, have taken appropriate security measures, and understand the risks involved.
|
||||
**Will authentication be added in the future?** Maybe. It's not currently a priority, but if there's enough demand for it, we may consider building in an optional authentication layer in a future release to support uses cases where multiple users need access to the same instance but with different permission levels (e.g. family use with parental controls, classroom use with teacher/admin accounts, etc.). 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 read this section fully to understand how to contribute to the project.
|
||||
|
||||
### General Guidelines
|
||||
|
||||
- **Open an issue first**: Before starting work on a new feature or bug fix, please open an issue to discuss your proposed changes. This helps ensure that your contribution aligns with the project's goals and avoids duplicate work. Title the issue clearly and provide a detailed description of the problem or feature you want to work on.
|
||||
- **Fork the repository**: Click the "Fork" button at the top right of the repository page to create a copy of the project under your GitHub account.
|
||||
- **Create a new branch**: In your forked repository, create a new branch for your work. Use a descriptive name for the branch that reflects the purpose of your changes (e.g., `fix/issue-123` or `feature/add-new-tool`).
|
||||
- **Make your changes**: Implement your changes in the new branch. Follow the existing code style and conventions used in the project. Be sure to test your changes locally to ensure they work as expected.
|
||||
- **Add Release Notes**: If your changes include new features, bug fixes, or improvements, please see the "Release Notes" section below to properly document your contribution for the next release.
|
||||
- **Conventional Commits**: When committing your changes, please use conventional commit messages to provide clear and consistent commit history. The format is `<type>(<scope>): <description>`, where:
|
||||
- `type` is the type of change (e.g., `feat` for new features, `fix` for bug fixes, `docs` for documentation changes, etc.)
|
||||
- `scope` is an optional area of the codebase that your change affects (e.g., `api`, `ui`, `docs`, etc.)
|
||||
- `description` is a brief summary of the change
|
||||
- **Submit a pull request**: Once your changes are ready, submit a pull request to the main repository. Provide a clear description of your changes and reference any related issues. The project maintainers will review your pull request and may provide feedback or request changes before it can be merged.
|
||||
- **Be responsive to feedback**: If the maintainers request changes or provide feedback on your pull request, please respond in a timely manner. Stale pull requests may be closed if there is no activity for an extended period.
|
||||
- **Follow the project's code of conduct**: Please adhere to the project's code of conduct when interacting with maintainers and other contributors. Be respectful and considerate in your communications.
|
||||
- **No guarantee of acceptance**: The project is community-driven, and all contributions are appreciated, but acceptance is not guaranteed. The maintainers will evaluate each contribution based on its quality, relevance, and alignment with the project's goals.
|
||||
- **Thank you for contributing to Project N.O.M.A.D.!** Your efforts help make this project better for everyone.
|
||||
|
||||
### Versioning
|
||||
This project uses semantic versioning. The version is managed in the root `package.json`
|
||||
and automatically updated by semantic-release. For simplicity's sake, the "project-nomad" image
|
||||
uses the same version defined there instead of the version in `admin/package.json` (stays at 0.0.0), as it's the only published image derived from the code.
|
||||
|
||||
### Release Notes
|
||||
Human-readable release notes live in [`admin/docs/release-notes.md`](admin/docs/release-notes.md) and are displayed in the Command Center's built-in documentation.
|
||||
|
||||
When working on changes, add a summary to the `## Unreleased` section at the top of that file under the appropriate heading:
|
||||
|
||||
- **Features** — new user-facing capabilities
|
||||
- **Bug Fixes** — corrections to existing behavior
|
||||
- **Improvements** — enhancements, refactors, docs, or dependency updates
|
||||
|
||||
Use the format `- **Area**: Description` to stay consistent with existing entries. When a release is triggered, CI automatically stamps the version and date, commits the update, and pushes the content to the GitHub release.
|
||||
Contributions are welcome and appreciated! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to contribute to the project.
|
||||
|
||||
## Community & Resources
|
||||
|
||||
- **Website:** [www.projectnomad.us](https://www.projectnomad.us) - Learn more about the project
|
||||
- **Discord:** [Join the Community](https://discord.com/invite/crosstalksolutions) - Get help, share your builds, and connect with other NOMAD users
|
||||
- **Benchmark Leaderboard:** [benchmark.projectnomad.us](https://benchmark.projectnomad.us) - See how your hardware stacks up against other NOMAD builds
|
||||
- **Troubleshooting Guide:** [TROUBLESHOOTING.md](TROUBLESHOOTING.md) - Find solutions to common issues
|
||||
- **FAQ:** [FAQ.md](FAQ.md) - Find answers to frequently asked questions
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,8 @@ export default defineConfig({
|
|||
() => import('@adonisjs/lucid/database_provider'),
|
||||
() => import('@adonisjs/inertia/inertia_provider'),
|
||||
() => import('@adonisjs/transmit/transmit_provider'),
|
||||
() => import('#providers/map_static_provider')
|
||||
() => import('#providers/map_static_provider'),
|
||||
() => import('#providers/kiwix_migration_provider'),
|
||||
],
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -15,4 +15,13 @@ export default class DownloadsController {
|
|||
const payload = await request.validateUsing(downloadJobsByFiletypeSchema)
|
||||
return this.downloadService.listDownloadJobs(payload.params.filetype)
|
||||
}
|
||||
|
||||
async removeJob({ params }: HttpContext) {
|
||||
await this.downloadService.removeFailedJob(params.jobId)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
async cancelJob({ params }: HttpContext) {
|
||||
return this.downloadService.cancelJob(params.jobId)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { SystemService } from '#services/system_service'
|
||||
import { ZimService } from '#services/zim_service'
|
||||
import { CollectionManifestService } from '#services/collection_manifest_service'
|
||||
import KVStore from '#models/kv_store'
|
||||
import { inject } from '@adonisjs/core'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
|
||||
|
|
@ -12,10 +13,14 @@ export default class EasySetupController {
|
|||
) {}
|
||||
|
||||
async index({ inertia }: HttpContext) {
|
||||
const services = await this.systemService.getServices({ installedOnly: false })
|
||||
const [services, remoteOllamaUrl] = await Promise.all([
|
||||
this.systemService.getServices({ installedOnly: false }),
|
||||
KVStore.getValue('ai.remoteOllamaUrl'),
|
||||
])
|
||||
return inertia.render('easy-setup/index', {
|
||||
system: {
|
||||
services: services,
|
||||
remoteOllamaUrl: remoteOllamaUrl ?? '',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { MapService } from '#services/map_service'
|
||||
import MapMarker from '#models/map_marker'
|
||||
import {
|
||||
assertNotPrivateUrl,
|
||||
downloadCollectionValidator,
|
||||
|
|
@ -8,6 +9,7 @@ import {
|
|||
} from '#validators/common'
|
||||
import { inject } from '@adonisjs/core'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
@inject()
|
||||
export default class MapsController {
|
||||
|
|
@ -73,6 +75,18 @@ export default class MapsController {
|
|||
return await this.mapService.listRegions()
|
||||
}
|
||||
|
||||
async globalMapInfo({}: HttpContext) {
|
||||
return await this.mapService.getGlobalMapInfo()
|
||||
}
|
||||
|
||||
async downloadGlobalMap({}: HttpContext) {
|
||||
const result = await this.mapService.downloadGlobalMap()
|
||||
return {
|
||||
message: 'Download started successfully',
|
||||
...result,
|
||||
}
|
||||
}
|
||||
|
||||
async styles({ request, response }: HttpContext) {
|
||||
// Automatically ensure base assets are present before generating styles
|
||||
const baseAssetsExist = await this.mapService.ensureBaseAssets()
|
||||
|
|
@ -83,7 +97,13 @@ export default class MapsController {
|
|||
})
|
||||
}
|
||||
|
||||
const styles = await this.mapService.generateStylesJSON(request.host())
|
||||
const forwardedProto = request.headers()['x-forwarded-proto'];
|
||||
|
||||
const protocol: string = forwardedProto
|
||||
? (typeof forwardedProto === 'string' ? forwardedProto : request.protocol())
|
||||
: request.protocol();
|
||||
|
||||
const styles = await this.mapService.generateStylesJSON(request.host(), protocol)
|
||||
return response.json(styles)
|
||||
}
|
||||
|
||||
|
|
@ -105,4 +125,60 @@ export default class MapsController {
|
|||
message: 'Map file deleted successfully',
|
||||
}
|
||||
}
|
||||
|
||||
// --- Map Markers ---
|
||||
|
||||
async listMarkers({}: HttpContext) {
|
||||
return await MapMarker.query().orderBy('created_at', 'asc')
|
||||
}
|
||||
|
||||
async createMarker({ request }: HttpContext) {
|
||||
const payload = await request.validateUsing(
|
||||
vine.compile(
|
||||
vine.object({
|
||||
name: vine.string().trim().minLength(1).maxLength(255),
|
||||
longitude: vine.number(),
|
||||
latitude: vine.number(),
|
||||
color: vine.string().trim().maxLength(20).optional(),
|
||||
})
|
||||
)
|
||||
)
|
||||
const marker = await MapMarker.create({
|
||||
name: payload.name,
|
||||
longitude: payload.longitude,
|
||||
latitude: payload.latitude,
|
||||
color: payload.color ?? 'orange',
|
||||
})
|
||||
return marker
|
||||
}
|
||||
|
||||
async updateMarker({ request, response }: HttpContext) {
|
||||
const { id } = request.params()
|
||||
const marker = await MapMarker.find(id)
|
||||
if (!marker) {
|
||||
return response.status(404).send({ message: 'Marker not found' })
|
||||
}
|
||||
const payload = await request.validateUsing(
|
||||
vine.compile(
|
||||
vine.object({
|
||||
name: vine.string().trim().minLength(1).maxLength(255).optional(),
|
||||
color: vine.string().trim().maxLength(20).optional(),
|
||||
})
|
||||
)
|
||||
)
|
||||
if (payload.name !== undefined) marker.name = payload.name
|
||||
if (payload.color !== undefined) marker.color = payload.color
|
||||
await marker.save()
|
||||
return marker
|
||||
}
|
||||
|
||||
async deleteMarker({ request, response }: HttpContext) {
|
||||
const { id } = request.params()
|
||||
const marker = await MapMarker.find(id)
|
||||
if (!marker) {
|
||||
return response.status(404).send({ message: 'Marker not found' })
|
||||
}
|
||||
await marker.delete()
|
||||
return { message: 'Marker deleted' }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,23 @@
|
|||
import { ChatService } from '#services/chat_service'
|
||||
import { DockerService } from '#services/docker_service'
|
||||
import { OllamaService } from '#services/ollama_service'
|
||||
import { RagService } from '#services/rag_service'
|
||||
import Service from '#models/service'
|
||||
import KVStore from '#models/kv_store'
|
||||
import { modelNameSchema } from '#validators/download'
|
||||
import { chatSchema, getAvailableModelsSchema } from '#validators/ollama'
|
||||
import { inject } from '@adonisjs/core'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import { DEFAULT_QUERY_REWRITE_MODEL, RAG_CONTEXT_LIMITS, SYSTEM_PROMPTS } from '../../constants/ollama.js'
|
||||
import { SERVICE_NAMES } from '../../constants/service_names.js'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import type { Message } from 'ollama'
|
||||
type Message = { role: 'system' | 'user' | 'assistant'; content: string }
|
||||
|
||||
@inject()
|
||||
export default class OllamaController {
|
||||
constructor(
|
||||
private chatService: ChatService,
|
||||
private dockerService: DockerService,
|
||||
private ollamaService: OllamaService,
|
||||
private ragService: RagService
|
||||
) { }
|
||||
|
|
@ -72,10 +77,10 @@ export default class OllamaController {
|
|||
const { maxResults, maxTokens } = this.getContextLimitsForModel(reqData.model)
|
||||
let trimmedDocs = relevantDocs.slice(0, maxResults)
|
||||
|
||||
// Apply token cap if set (estimate ~4 chars per token)
|
||||
// Apply token cap if set (estimate ~3.5 chars per token)
|
||||
// Always include the first (most relevant) result — the cap only gates subsequent results
|
||||
if (maxTokens > 0) {
|
||||
const charCap = maxTokens * 4
|
||||
const charCap = maxTokens * 3.5
|
||||
let totalChars = 0
|
||||
trimmedDocs = trimmedDocs.filter((doc, idx) => {
|
||||
totalChars += doc.text.length
|
||||
|
|
@ -103,6 +108,19 @@ export default class OllamaController {
|
|||
}
|
||||
}
|
||||
|
||||
// If system messages are large (e.g. due to RAG context), request a context window big
|
||||
// enough to fit them. Ollama respects num_ctx per-request; LM Studio ignores it gracefully.
|
||||
const systemChars = reqData.messages
|
||||
.filter((m) => m.role === 'system')
|
||||
.reduce((sum, m) => sum + m.content.length, 0)
|
||||
const estimatedSystemTokens = Math.ceil(systemChars / 3.5)
|
||||
let numCtx: number | undefined
|
||||
if (estimatedSystemTokens > 3000) {
|
||||
const needed = estimatedSystemTokens + 2048 // leave room for conversation + response
|
||||
numCtx = [8192, 16384, 32768, 65536].find((n) => n >= needed) ?? 65536
|
||||
logger.debug(`[OllamaController] Large system prompt (~${estimatedSystemTokens} tokens), requesting num_ctx: ${numCtx}`)
|
||||
}
|
||||
|
||||
// Check if the model supports "thinking" capability for enhanced response generation
|
||||
// If gpt-oss model, it requires a text param for "think" https://docs.ollama.com/api/chat
|
||||
const thinkingCapability = await this.ollamaService.checkModelHasThinking(reqData.model)
|
||||
|
|
@ -124,7 +142,7 @@ export default class OllamaController {
|
|||
if (reqData.stream) {
|
||||
logger.debug(`[OllamaController] Initiating streaming response for model: "${reqData.model}" with think: ${think}`)
|
||||
// Headers already flushed above
|
||||
const stream = await this.ollamaService.chatStream({ ...ollamaRequest, think })
|
||||
const stream = await this.ollamaService.chatStream({ ...ollamaRequest, think, numCtx })
|
||||
let fullContent = ''
|
||||
for await (const chunk of stream) {
|
||||
if (chunk.message?.content) {
|
||||
|
|
@ -148,7 +166,7 @@ export default class OllamaController {
|
|||
}
|
||||
|
||||
// Non-streaming (legacy) path
|
||||
const result = await this.ollamaService.chat({ ...ollamaRequest, think })
|
||||
const result = await this.ollamaService.chat({ ...ollamaRequest, think, numCtx })
|
||||
|
||||
if (sessionId && result?.message?.content) {
|
||||
await this.chatService.addMessage(sessionId, 'assistant', result.message.content)
|
||||
|
|
@ -171,6 +189,87 @@ export default class OllamaController {
|
|||
}
|
||||
}
|
||||
|
||||
async remoteStatus() {
|
||||
const remoteUrl = await KVStore.getValue('ai.remoteOllamaUrl')
|
||||
if (!remoteUrl) {
|
||||
return { configured: false, connected: false }
|
||||
}
|
||||
try {
|
||||
const testResponse = await fetch(`${remoteUrl.replace(/\/$/, '')}/v1/models`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
return { configured: true, connected: testResponse.ok }
|
||||
} catch {
|
||||
return { configured: true, connected: false }
|
||||
}
|
||||
}
|
||||
|
||||
async configureRemote({ request, response }: HttpContext) {
|
||||
const remoteUrl: string | null = request.input('remoteUrl', null)
|
||||
|
||||
const ollamaService = await Service.query().where('service_name', SERVICE_NAMES.OLLAMA).first()
|
||||
if (!ollamaService) {
|
||||
return response.status(404).send({ success: false, message: 'Ollama service record not found.' })
|
||||
}
|
||||
|
||||
// Clear path: null or empty URL removes remote config and marks service as not installed
|
||||
if (!remoteUrl || remoteUrl.trim() === '') {
|
||||
await KVStore.clearValue('ai.remoteOllamaUrl')
|
||||
ollamaService.installed = false
|
||||
ollamaService.installation_status = 'idle'
|
||||
await ollamaService.save()
|
||||
return { success: true, message: 'Remote Ollama configuration cleared.' }
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
if (!remoteUrl.startsWith('http')) {
|
||||
return response.status(400).send({
|
||||
success: false,
|
||||
message: 'Invalid URL. Must start with http:// or https://',
|
||||
})
|
||||
}
|
||||
|
||||
// Test connectivity via OpenAI-compatible /v1/models endpoint (works with Ollama, LM Studio, llama.cpp, etc.)
|
||||
try {
|
||||
const testResponse = await fetch(`${remoteUrl.replace(/\/$/, '')}/v1/models`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
if (!testResponse.ok) {
|
||||
return response.status(400).send({
|
||||
success: false,
|
||||
message: `Could not connect to ${remoteUrl} (HTTP ${testResponse.status}). Make sure the server is running and accessible. For Ollama, start it with OLLAMA_HOST=0.0.0.0.`,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
return response.status(400).send({
|
||||
success: false,
|
||||
message: `Could not connect to ${remoteUrl}. Make sure the server is running and reachable. For Ollama, start it with OLLAMA_HOST=0.0.0.0.`,
|
||||
})
|
||||
}
|
||||
|
||||
// Save remote URL and mark service as installed
|
||||
await KVStore.setValue('ai.remoteOllamaUrl', remoteUrl.trim())
|
||||
ollamaService.installed = true
|
||||
ollamaService.installation_status = 'idle'
|
||||
await ollamaService.save()
|
||||
|
||||
// Install Qdrant if not already installed (fire-and-forget)
|
||||
const qdrantService = await Service.query().where('service_name', SERVICE_NAMES.QDRANT).first()
|
||||
if (qdrantService && !qdrantService.installed) {
|
||||
this.dockerService.createContainerPreflight(SERVICE_NAMES.QDRANT).catch((error) => {
|
||||
logger.error('[OllamaController] Failed to start Qdrant preflight:', error)
|
||||
})
|
||||
}
|
||||
|
||||
// Mirror post-install side effects: disable suggestions, trigger docs discovery
|
||||
await KVStore.setValue('chat.suggestionsEnabled', false)
|
||||
this.ragService.discoverNomadDocs().catch((error) => {
|
||||
logger.error('[OllamaController] Failed to discover Nomad docs:', error)
|
||||
})
|
||||
|
||||
return { success: true, message: 'Remote Ollama configured.' }
|
||||
}
|
||||
|
||||
async deleteModel({ request }: HttpContext) {
|
||||
const reqData = await request.validateUsing(modelNameSchema)
|
||||
await this.ollamaService.deleteModel(reqData.model)
|
||||
|
|
|
|||
|
|
@ -74,6 +74,19 @@ export default class RagController {
|
|||
return response.status(200).json({ message: result.message })
|
||||
}
|
||||
|
||||
public async getFailedJobs({ response }: HttpContext) {
|
||||
const jobs = await EmbedFileJob.listFailedJobs()
|
||||
return response.status(200).json(jobs)
|
||||
}
|
||||
|
||||
public async cleanupFailedJobs({ response }: HttpContext) {
|
||||
const result = await EmbedFileJob.cleanupFailedJobs()
|
||||
return response.status(200).json({
|
||||
message: `Cleaned up ${result.cleaned} failed job${result.cleaned !== 1 ? 's' : ''}${result.filesDeleted > 0 ? `, deleted ${result.filesDeleted} file${result.filesDeleted !== 1 ? 's' : ''}` : ''}.`,
|
||||
...result,
|
||||
})
|
||||
}
|
||||
|
||||
public async scanAndSync({ response }: HttpContext) {
|
||||
try {
|
||||
const syncResult = await this.ragService.scanAndSyncStorage()
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import KVStore from '#models/kv_store';
|
||||
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 { updateSettingSchema } from '#validators/settings';
|
||||
import { inject } from '@adonisjs/core';
|
||||
import KVStore from '#models/kv_store'
|
||||
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 { inject } from '@adonisjs/core'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import type { KVStoreKey } from '../../types/kv_store.js';
|
||||
|
||||
@inject()
|
||||
export default class SettingsController {
|
||||
|
|
@ -18,43 +17,54 @@ export default class SettingsController {
|
|||
) {}
|
||||
|
||||
async system({ inertia }: HttpContext) {
|
||||
const systemInfo = await this.systemService.getSystemInfo();
|
||||
const systemInfo = await this.systemService.getSystemInfo()
|
||||
return inertia.render('settings/system', {
|
||||
system: {
|
||||
info: systemInfo
|
||||
}
|
||||
});
|
||||
info: systemInfo,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async apps({ inertia }: HttpContext) {
|
||||
const services = await this.systemService.getServices({ installedOnly: false });
|
||||
const services = await this.systemService.getServices({ installedOnly: false })
|
||||
return inertia.render('settings/apps', {
|
||||
system: {
|
||||
services
|
||||
}
|
||||
});
|
||||
services,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async legal({ inertia }: HttpContext) {
|
||||
return inertia.render('settings/legal');
|
||||
return inertia.render('settings/legal')
|
||||
}
|
||||
|
||||
async support({ inertia }: HttpContext) {
|
||||
return inertia.render('settings/support')
|
||||
}
|
||||
|
||||
async maps({ inertia }: HttpContext) {
|
||||
const baseAssetsCheck = await this.mapService.ensureBaseAssets();
|
||||
const regionFiles = await this.mapService.listRegions();
|
||||
const baseAssetsCheck = await this.mapService.ensureBaseAssets()
|
||||
const regionFiles = await this.mapService.listRegions()
|
||||
return inertia.render('settings/maps', {
|
||||
maps: {
|
||||
baseAssetsExist: baseAssetsCheck,
|
||||
regionFiles: regionFiles.files
|
||||
}
|
||||
});
|
||||
regionFiles: regionFiles.files,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async models({ inertia }: HttpContext) {
|
||||
const availableModels = await this.ollamaService.getAvailableModels({ sort: 'pulls', recommendedOnly: false, query: null, limit: 15 });
|
||||
const installedModels = await this.ollamaService.getModels();
|
||||
const availableModels = await this.ollamaService.getAvailableModels({
|
||||
sort: 'pulls',
|
||||
recommendedOnly: false,
|
||||
query: null,
|
||||
limit: 15,
|
||||
})
|
||||
const installedModels = await this.ollamaService.getModels().catch(() => [])
|
||||
const chatSuggestionsEnabled = await KVStore.getValue('chat.suggestionsEnabled')
|
||||
const aiAssistantCustomName = await KVStore.getValue('ai.assistantCustomName')
|
||||
const remoteOllamaUrl = await KVStore.getValue('ai.remoteOllamaUrl')
|
||||
const ollamaFlashAttention = await KVStore.getValue('ai.ollamaFlashAttention')
|
||||
return inertia.render('settings/models', {
|
||||
models: {
|
||||
availableModels: availableModels?.models || [],
|
||||
|
|
@ -62,20 +72,22 @@ export default class SettingsController {
|
|||
settings: {
|
||||
chatSuggestionsEnabled: chatSuggestionsEnabled ?? false,
|
||||
aiAssistantCustomName: aiAssistantCustomName ?? '',
|
||||
}
|
||||
}
|
||||
});
|
||||
remoteOllamaUrl: remoteOllamaUrl ?? '',
|
||||
ollamaFlashAttention: ollamaFlashAttention ?? true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async update({ inertia }: HttpContext) {
|
||||
const updateInfo = await this.systemService.checkLatestVersion();
|
||||
const updateInfo = await this.systemService.checkLatestVersion()
|
||||
return inertia.render('settings/update', {
|
||||
system: {
|
||||
updateAvailable: updateInfo.updateAvailable,
|
||||
latestVersion: updateInfo.latestVersion,
|
||||
currentVersion: updateInfo.currentVersion
|
||||
}
|
||||
});
|
||||
currentVersion: updateInfo.currentVersion,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async zim({ inertia }: HttpContext) {
|
||||
|
|
@ -83,30 +95,30 @@ export default class SettingsController {
|
|||
}
|
||||
|
||||
async zimRemote({ inertia }: HttpContext) {
|
||||
return inertia.render('settings/zim/remote-explorer');
|
||||
return inertia.render('settings/zim/remote-explorer')
|
||||
}
|
||||
|
||||
async benchmark({ inertia }: HttpContext) {
|
||||
const latestResult = await this.benchmarkService.getLatestResult();
|
||||
const status = this.benchmarkService.getStatus();
|
||||
const latestResult = await this.benchmarkService.getLatestResult()
|
||||
const status = this.benchmarkService.getStatus()
|
||||
return inertia.render('settings/benchmark', {
|
||||
benchmark: {
|
||||
latestResult,
|
||||
status: status.status,
|
||||
currentBenchmarkId: status.benchmarkId
|
||||
}
|
||||
});
|
||||
currentBenchmarkId: status.benchmarkId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async getSetting({ request, response }: HttpContext) {
|
||||
const key = request.qs().key;
|
||||
const value = await KVStore.getValue(key as KVStoreKey);
|
||||
const { key } = await getSettingSchema.validate({ key: request.qs().key });
|
||||
const value = await KVStore.getValue(key);
|
||||
return response.status(200).send({ key, value });
|
||||
}
|
||||
|
||||
async updateSetting({ request, response }: HttpContext) {
|
||||
const reqData = await request.validateUsing(updateSettingSchema);
|
||||
await this.systemService.updateSetting(reqData.key, reqData.value);
|
||||
return response.status(200).send({ success: true, message: 'Setting updated successfully' });
|
||||
const reqData = await request.validateUsing(updateSettingSchema)
|
||||
await this.systemService.updateSetting(reqData.key, reqData.value)
|
||||
return response.status(200).send({ success: true, message: 'Setting updated successfully' })
|
||||
}
|
||||
}
|
||||
|
|
@ -35,7 +35,7 @@ export default class SystemController {
|
|||
if (result.success) {
|
||||
response.send({ success: true, message: result.message });
|
||||
} else {
|
||||
response.status(400).send({ error: result.message });
|
||||
response.status(400).send({ success: false, message: result.message });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -113,6 +113,11 @@ export default class SystemController {
|
|||
return await this.systemService.subscribeToReleaseNotes(reqData.email);
|
||||
}
|
||||
|
||||
async getDebugInfo({}: HttpContext) {
|
||||
const debugInfo = await this.systemService.getDebugInfo()
|
||||
return { debugInfo }
|
||||
}
|
||||
|
||||
async checkServiceUpdates({ response }: HttpContext) {
|
||||
await CheckServiceUpdatesJob.dispatch()
|
||||
response.send({ success: true, message: 'Service update check dispatched' })
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export default class ZimController {
|
|||
async downloadRemote({ request }: HttpContext) {
|
||||
const payload = await request.validateUsing(remoteDownloadWithMetadataValidator)
|
||||
assertNotPrivateUrl(payload.url)
|
||||
const { filename, jobId } = await this.zimService.downloadRemote(payload.url)
|
||||
const { filename, jobId } = await this.zimService.downloadRemote(payload.url, payload.metadata)
|
||||
|
||||
return {
|
||||
message: 'Download started successfully',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Job } from 'bullmq'
|
||||
import { Job, UnrecoverableError } from 'bullmq'
|
||||
import { QueueService } from '#services/queue_service'
|
||||
import { createHash } from 'crypto'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
|
|
@ -44,7 +44,9 @@ export class DownloadModelJob {
|
|||
// Services are ready, initiate the download with progress tracking
|
||||
const result = await ollamaService.downloadModel(modelName, (progressPercent) => {
|
||||
if (progressPercent) {
|
||||
job.updateProgress(Math.floor(progressPercent))
|
||||
job.updateProgress(Math.floor(progressPercent)).catch((err) => {
|
||||
if (err?.code !== -1) throw err
|
||||
})
|
||||
logger.info(
|
||||
`[DownloadModelJob] Model ${modelName}: ${progressPercent}%`
|
||||
)
|
||||
|
|
@ -56,6 +58,8 @@ export class DownloadModelJob {
|
|||
status: 'downloading',
|
||||
progress: progressPercent,
|
||||
progress_timestamp: new Date().toISOString(),
|
||||
}).catch((err) => {
|
||||
if (err?.code !== -1) throw err
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -63,6 +67,10 @@ export class DownloadModelJob {
|
|||
logger.error(
|
||||
`[DownloadModelJob] Failed to initiate download for model ${modelName}: ${result.message}`
|
||||
)
|
||||
// Don't retry errors that will never succeed (e.g., Ollama version too old)
|
||||
if (result.retryable === false) {
|
||||
throw new UnrecoverableError(result.message)
|
||||
}
|
||||
throw new Error(`Failed to initiate download for model: ${result.message}`)
|
||||
}
|
||||
|
||||
|
|
@ -85,6 +93,15 @@ export class DownloadModelJob {
|
|||
const queue = queueService.getQueue(this.queue)
|
||||
const jobId = this.getJobId(params.modelName)
|
||||
|
||||
// Clear any previous failed job so a fresh attempt can be dispatched
|
||||
const existing = await queue.getJob(jobId)
|
||||
if (existing) {
|
||||
const state = await existing.getState()
|
||||
if (state === 'failed') {
|
||||
await existing.remove()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const job = await queue.add(this.key, params, {
|
||||
jobId,
|
||||
|
|
@ -104,9 +121,9 @@ export class DownloadModelJob {
|
|||
}
|
||||
} catch (error) {
|
||||
if (error.message.includes('job already exists')) {
|
||||
const existing = await queue.getJob(jobId)
|
||||
const active = await queue.getJob(jobId)
|
||||
return {
|
||||
job: existing,
|
||||
job: active,
|
||||
created: false,
|
||||
message: `Job already exists for model ${params.modelName}`,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Job } from 'bullmq'
|
||||
import { Job, UnrecoverableError } from 'bullmq'
|
||||
import { QueueService } from '#services/queue_service'
|
||||
import { EmbedJobWithProgress } from '../../types/rag.js'
|
||||
import { RagService } from '#services/rag_service'
|
||||
|
|
@ -6,6 +6,7 @@ import { DockerService } from '#services/docker_service'
|
|||
import { OllamaService } from '#services/ollama_service'
|
||||
import { createHash } from 'crypto'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import fs from 'node:fs/promises'
|
||||
|
||||
export interface EmbedFileJobParams {
|
||||
filePath: string
|
||||
|
|
@ -30,6 +31,17 @@ export class EmbedFileJob {
|
|||
return createHash('sha256').update(filePath).digest('hex').slice(0, 16)
|
||||
}
|
||||
|
||||
/** Calls job.updateProgress but silently ignores "Missing key" errors (code -1),
|
||||
* which occur when the job has been removed from Redis (e.g. cancelled externally)
|
||||
* between the time the await was issued and the Redis write completed. */
|
||||
private async safeUpdateProgress(job: Job, progress: number): Promise<void> {
|
||||
try {
|
||||
await job.updateProgress(progress)
|
||||
} catch (err: any) {
|
||||
if (err?.code !== -1) throw err
|
||||
}
|
||||
}
|
||||
|
||||
async handle(job: Job) {
|
||||
const { filePath, fileName, batchOffset, totalArticles } = job.data as EmbedFileJobParams
|
||||
|
||||
|
|
@ -42,7 +54,15 @@ export class EmbedFileJob {
|
|||
const ragService = new RagService(dockerService, ollamaService)
|
||||
|
||||
try {
|
||||
// Check if Ollama and Qdrant services are ready
|
||||
// Check if Ollama and Qdrant services are installed and ready
|
||||
// Use UnrecoverableError for "not installed" so BullMQ won't retry —
|
||||
// retrying 30x when the service doesn't exist just wastes Redis connections
|
||||
const ollamaUrl = await dockerService.getServiceURL('nomad_ollama')
|
||||
if (!ollamaUrl) {
|
||||
logger.warn('[EmbedFileJob] Ollama is not installed. Skipping embedding for: %s', fileName)
|
||||
throw new UnrecoverableError('Ollama service is not installed. Install AI Assistant to enable file embeddings.')
|
||||
}
|
||||
|
||||
const existingModels = await ollamaService.getModels()
|
||||
if (!existingModels) {
|
||||
logger.warn('[EmbedFileJob] Ollama service not ready yet. Will retry...')
|
||||
|
|
@ -51,14 +71,14 @@ export class EmbedFileJob {
|
|||
|
||||
const qdrantUrl = await dockerService.getServiceURL('nomad_qdrant')
|
||||
if (!qdrantUrl) {
|
||||
logger.warn('[EmbedFileJob] Qdrant service not ready yet. Will retry...')
|
||||
throw new Error('Qdrant service not ready yet')
|
||||
logger.warn('[EmbedFileJob] Qdrant is not installed. Skipping embedding for: %s', fileName)
|
||||
throw new UnrecoverableError('Qdrant service is not installed. Install AI Assistant to enable file embeddings.')
|
||||
}
|
||||
|
||||
logger.info(`[EmbedFileJob] Services ready. Processing file: ${fileName}`)
|
||||
|
||||
// Update progress starting
|
||||
await job.updateProgress(5)
|
||||
await this.safeUpdateProgress(job, 5)
|
||||
await job.updateData({
|
||||
...job.data,
|
||||
status: 'processing',
|
||||
|
|
@ -69,7 +89,7 @@ export class EmbedFileJob {
|
|||
|
||||
// Progress callback: maps service-reported 0-100% into the 5-95% job range
|
||||
const onProgress = async (percent: number) => {
|
||||
await job.updateProgress(Math.min(95, Math.round(5 + percent * 0.9)))
|
||||
await this.safeUpdateProgress(job, Math.min(95, Math.round(5 + percent * 0.9)))
|
||||
}
|
||||
|
||||
// Process and embed the file
|
||||
|
|
@ -108,7 +128,7 @@ export class EmbedFileJob {
|
|||
? Math.round((nextOffset / totalArticles) * 100)
|
||||
: 50
|
||||
|
||||
await job.updateProgress(progress)
|
||||
await this.safeUpdateProgress(job, progress)
|
||||
await job.updateData({
|
||||
...job.data,
|
||||
status: 'batch_completed',
|
||||
|
|
@ -129,7 +149,7 @@ export class EmbedFileJob {
|
|||
|
||||
// Final batch or non-batched file - mark as complete
|
||||
const totalChunks = (job.data.chunks || 0) + (result.chunks || 0)
|
||||
await job.updateProgress(100)
|
||||
await this.safeUpdateProgress(job, 100)
|
||||
await job.updateData({
|
||||
...job.data,
|
||||
status: 'completed',
|
||||
|
|
@ -224,6 +244,52 @@ export class EmbedFileJob {
|
|||
}
|
||||
}
|
||||
|
||||
static async listFailedJobs(): Promise<EmbedJobWithProgress[]> {
|
||||
const queueService = new QueueService()
|
||||
const queue = queueService.getQueue(this.queue)
|
||||
// Jobs that have failed at least once are in 'delayed' (retrying) or terminal 'failed' state.
|
||||
// We identify them by job.data.status === 'failed' set in the catch block of handle().
|
||||
const jobs = await queue.getJobs(['waiting', 'delayed', 'failed'])
|
||||
|
||||
return jobs
|
||||
.filter((job) => (job.data as any).status === 'failed')
|
||||
.map((job) => ({
|
||||
jobId: job.id!.toString(),
|
||||
fileName: (job.data as EmbedFileJobParams).fileName,
|
||||
filePath: (job.data as EmbedFileJobParams).filePath,
|
||||
progress: 0,
|
||||
status: 'failed',
|
||||
error: (job.data as any).error,
|
||||
}))
|
||||
}
|
||||
|
||||
static async cleanupFailedJobs(): Promise<{ cleaned: number; filesDeleted: number }> {
|
||||
const queueService = new QueueService()
|
||||
const queue = queueService.getQueue(this.queue)
|
||||
const allJobs = await queue.getJobs(['waiting', 'delayed', 'failed'])
|
||||
const failedJobs = allJobs.filter((job) => (job.data as any).status === 'failed')
|
||||
|
||||
let cleaned = 0
|
||||
let filesDeleted = 0
|
||||
|
||||
for (const job of failedJobs) {
|
||||
const filePath = (job.data as EmbedFileJobParams).filePath
|
||||
if (filePath && filePath.includes(RagService.UPLOADS_STORAGE_PATH)) {
|
||||
try {
|
||||
await fs.unlink(filePath)
|
||||
filesDeleted++
|
||||
} catch {
|
||||
// File may already be deleted — that's fine
|
||||
}
|
||||
}
|
||||
await job.remove()
|
||||
cleaned++
|
||||
}
|
||||
|
||||
logger.info(`[EmbedFileJob] Cleaned up ${cleaned} failed jobs, deleted ${filesDeleted} files`)
|
||||
return { cleaned, filesDeleted }
|
||||
}
|
||||
|
||||
static async getStatus(filePath: string): Promise<{
|
||||
exists: boolean
|
||||
status?: string
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Job } from 'bullmq'
|
||||
import { RunDownloadJobParams } from '../../types/downloads.js'
|
||||
import { Job, UnrecoverableError } from 'bullmq'
|
||||
import { RunDownloadJobParams, DownloadProgressData } from '../../types/downloads.js'
|
||||
import { QueueService } from '#services/queue_service'
|
||||
import { doResumableDownload } from '../utils/downloads.js'
|
||||
import { createHash } from 'crypto'
|
||||
|
|
@ -17,23 +17,85 @@ export class RunDownloadJob {
|
|||
return 'run-download'
|
||||
}
|
||||
|
||||
/** In-memory registry of abort controllers for active download jobs */
|
||||
static abortControllers: Map<string, AbortController> = new Map()
|
||||
|
||||
static getJobId(url: string): string {
|
||||
return createHash('sha256').update(url).digest('hex').slice(0, 16)
|
||||
}
|
||||
|
||||
/** Redis key used to signal cancellation across processes */
|
||||
static cancelKey(jobId: string): string {
|
||||
return `nomad:download:cancel:${jobId}`
|
||||
}
|
||||
|
||||
/** Signal cancellation via Redis so the worker process can pick it up */
|
||||
static async signalCancel(jobId: string): Promise<void> {
|
||||
const queueService = new QueueService()
|
||||
const queue = queueService.getQueue(this.queue)
|
||||
const client = await queue.client
|
||||
await client.set(this.cancelKey(jobId), '1', 'EX', 300) // 5 min TTL
|
||||
}
|
||||
|
||||
async handle(job: Job) {
|
||||
const { url, filepath, timeout, allowedMimeTypes, forceNew, filetype, resourceMetadata } =
|
||||
job.data as RunDownloadJobParams
|
||||
|
||||
// Register abort controller for this job
|
||||
const abortController = new AbortController()
|
||||
RunDownloadJob.abortControllers.set(job.id!, abortController)
|
||||
|
||||
// Get Redis client for checking cancel signals from the API process
|
||||
const queueService = new QueueService()
|
||||
const cancelRedis = await queueService.getQueue(RunDownloadJob.queue).client
|
||||
|
||||
let lastKnownProgress: Pick<DownloadProgressData, 'downloadedBytes' | 'totalBytes'> = {
|
||||
downloadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
}
|
||||
|
||||
// Track whether cancellation was explicitly requested by the user (via Redis signal
|
||||
// or in-process AbortController). BullMQ lock mismatches can also abort the download
|
||||
// stream, but those should be retried — only user-initiated cancels are unrecoverable.
|
||||
let userCancelled = false
|
||||
|
||||
// Poll Redis for cancel signal every 2s — independent of progress events so cancellation
|
||||
// works even when the stream is stalled and no onProgress ticks are firing.
|
||||
let cancelPollInterval: ReturnType<typeof setInterval> | null = setInterval(async () => {
|
||||
try {
|
||||
const val = await cancelRedis.get(RunDownloadJob.cancelKey(job.id!))
|
||||
if (val) {
|
||||
await cancelRedis.del(RunDownloadJob.cancelKey(job.id!))
|
||||
userCancelled = true
|
||||
abortController.abort('user-cancel')
|
||||
}
|
||||
} catch {
|
||||
// Redis errors are non-fatal; in-process AbortController covers same-process cancels
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
try {
|
||||
await doResumableDownload({
|
||||
url,
|
||||
filepath,
|
||||
timeout,
|
||||
allowedMimeTypes,
|
||||
forceNew,
|
||||
signal: abortController.signal,
|
||||
onProgress(progress) {
|
||||
const progressPercent = (progress.downloadedBytes / (progress.totalBytes || 1)) * 100
|
||||
job.updateProgress(Math.floor(progressPercent))
|
||||
const progressData: DownloadProgressData = {
|
||||
percent: Math.floor(progressPercent),
|
||||
downloadedBytes: progress.downloadedBytes,
|
||||
totalBytes: progress.totalBytes,
|
||||
lastProgressTime: Date.now(),
|
||||
}
|
||||
job.updateProgress(progressData).catch((err) => {
|
||||
// Job was removed from Redis (e.g. cancelled) between the callback firing
|
||||
// and the Redis write completing — this is expected and safe to ignore.
|
||||
if (err?.code !== -1) throw err
|
||||
})
|
||||
lastKnownProgress = { downloadedBytes: progress.downloadedBytes, totalBytes: progress.totalBytes }
|
||||
},
|
||||
async onComplete(url) {
|
||||
try {
|
||||
|
|
@ -82,7 +144,9 @@ export class RunDownloadJob {
|
|||
const zimService = new ZimService(dockerService)
|
||||
await zimService.downloadRemoteSuccessCallback([url], true)
|
||||
|
||||
// Dispatch an embedding job for the downloaded ZIM file
|
||||
// Only dispatch embedding job if AI Assistant (Ollama) is installed
|
||||
const ollamaUrl = await dockerService.getServiceURL('nomad_ollama')
|
||||
if (ollamaUrl) {
|
||||
try {
|
||||
await EmbedFileJob.dispatch({
|
||||
fileName: url.split('/').pop() || '',
|
||||
|
|
@ -91,6 +155,7 @@ export class RunDownloadJob {
|
|||
} catch (error) {
|
||||
console.error(`[RunDownloadJob] Error dispatching EmbedFileJob for URL ${url}:`, error)
|
||||
}
|
||||
}
|
||||
} else if (filetype === 'map') {
|
||||
const mapsService = new MapService()
|
||||
await mapsService.downloadRemoteSuccessCallback([url], false)
|
||||
|
|
@ -101,7 +166,14 @@ export class RunDownloadJob {
|
|||
error
|
||||
)
|
||||
}
|
||||
job.updateProgress(100)
|
||||
job.updateProgress({
|
||||
percent: 100,
|
||||
downloadedBytes: lastKnownProgress.downloadedBytes,
|
||||
totalBytes: lastKnownProgress.totalBytes,
|
||||
lastProgressTime: Date.now(),
|
||||
} as DownloadProgressData).catch((err) => {
|
||||
if (err?.code !== -1) throw err
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -109,6 +181,21 @@ export class RunDownloadJob {
|
|||
url,
|
||||
filepath,
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Only prevent retries for user-initiated cancellations. BullMQ lock mismatches
|
||||
// can also abort the stream, and those should be retried with backoff.
|
||||
// Check both the flag (Redis poll) and abort reason (in-process cancel).
|
||||
if (userCancelled || abortController.signal.reason === 'user-cancel') {
|
||||
throw new UnrecoverableError(`Download cancelled: ${error.message}`)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
if (cancelPollInterval !== null) {
|
||||
clearInterval(cancelPollInterval)
|
||||
cancelPollInterval = null
|
||||
}
|
||||
RunDownloadJob.abortControllers.delete(job.id!)
|
||||
}
|
||||
}
|
||||
|
||||
static async getByUrl(url: string): Promise<Job | undefined> {
|
||||
|
|
@ -118,6 +205,29 @@ export class RunDownloadJob {
|
|||
return await queue.getJob(jobId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a download is actively in progress for the given URL.
|
||||
* Returns the job only if it's in an active state (active, waiting, delayed).
|
||||
* If the job exists in a terminal state (failed, completed), removes it and returns undefined.
|
||||
*/
|
||||
static async getActiveByUrl(url: string): Promise<Job | undefined> {
|
||||
const job = await this.getByUrl(url)
|
||||
if (!job) return undefined
|
||||
|
||||
const state = await job.getState()
|
||||
if (state === 'active' || state === 'waiting' || state === 'delayed') {
|
||||
return job
|
||||
}
|
||||
|
||||
// Terminal state -- clean up stale job so it doesn't block re-download
|
||||
try {
|
||||
await job.remove()
|
||||
} catch {
|
||||
// May already be gone
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
static async dispatch(params: RunDownloadJobParams) {
|
||||
const queueService = new QueueService()
|
||||
const queue = queueService.getQueue(this.queue)
|
||||
|
|
@ -126,8 +236,8 @@ export class RunDownloadJob {
|
|||
try {
|
||||
const job = await queue.add(this.key, params, {
|
||||
jobId,
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 2000 },
|
||||
attempts: 10,
|
||||
backoff: { type: 'exponential', delay: 30000 },
|
||||
removeOnComplete: true,
|
||||
})
|
||||
|
||||
|
|
|
|||
21
admin/app/middleware/compression_middleware.ts
Normal file
21
admin/app/middleware/compression_middleware.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import env from '#start/env'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import type { NextFn } from '@adonisjs/core/types/http'
|
||||
import compression from 'compression'
|
||||
|
||||
const compress = env.get('DISABLE_COMPRESSION') ? null : compression()
|
||||
|
||||
export default class CompressionMiddleware {
|
||||
async handle({ request, response }: HttpContext, next: NextFn) {
|
||||
if (!compress) return await next()
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
compress(request.request as any, response.response as any, (err?: any) => {
|
||||
if (err) reject(err)
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
|
||||
await next()
|
||||
}
|
||||
}
|
||||
43
admin/app/models/map_marker.ts
Normal file
43
admin/app/models/map_marker.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { DateTime } from 'luxon'
|
||||
import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
|
||||
|
||||
export default class MapMarker extends BaseModel {
|
||||
static namingStrategy = new SnakeCaseNamingStrategy()
|
||||
|
||||
@column({ isPrimary: true })
|
||||
declare id: number
|
||||
|
||||
@column()
|
||||
declare name: string
|
||||
|
||||
@column()
|
||||
declare longitude: number
|
||||
|
||||
@column()
|
||||
declare latitude: number
|
||||
|
||||
@column()
|
||||
declare color: string
|
||||
|
||||
// 'pin' for user-placed markers, 'waypoint' for route points (future)
|
||||
@column()
|
||||
declare marker_type: string
|
||||
|
||||
// Groups markers into a route (future)
|
||||
@column()
|
||||
declare route_id: string | null
|
||||
|
||||
// Order within a route (future)
|
||||
@column()
|
||||
declare route_order: number | null
|
||||
|
||||
// Optional user notes for a location
|
||||
@column()
|
||||
declare notes: string | null
|
||||
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare created_at: DateTime
|
||||
|
||||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||
declare updated_at: DateTime
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ import Dockerode from 'dockerode'
|
|||
// This provides basic protection against casual API abuse.
|
||||
// Note: Since NOMAD is open source, a determined attacker could extract this.
|
||||
// For stronger protection, see challenge-response authentication.
|
||||
const BENCHMARK_HMAC_SECRET = 'nomad-benchmark-v1-2026'
|
||||
const BENCHMARK_HMAC_SECRET = '778ba65d0bc0e23119e5ffce4b3716648a7d071f0a47ec3f'
|
||||
|
||||
// Re-export default weights for use in service
|
||||
const SCORE_WEIGHTS = {
|
||||
|
|
@ -571,10 +571,10 @@ export class BenchmarkService {
|
|||
*/
|
||||
private _normalizeScore(value: number, reference: number): number {
|
||||
if (value <= 0) return 0
|
||||
// Log scale: score = 50 * (1 + log2(value/reference))
|
||||
// This gives 50 at reference value, scales logarithmically
|
||||
// Log scale with widened range: dividing log2 by 3 prevents scores from
|
||||
// clamping to 0% for below-average hardware. Gives 50% at reference value.
|
||||
const ratio = value / reference
|
||||
const score = 50 * (1 + Math.log2(Math.max(0.01, ratio)))
|
||||
const score = 50 * (1 + Math.log2(Math.max(0.01, ratio)) / 3)
|
||||
return Math.min(100, Math.max(0, score)) / 100
|
||||
}
|
||||
|
||||
|
|
@ -583,9 +583,9 @@ export class BenchmarkService {
|
|||
*/
|
||||
private _normalizeScoreInverse(value: number, reference: number): number {
|
||||
if (value <= 0) return 1
|
||||
// Inverse: lower values = higher scores
|
||||
// Inverse: lower values = higher scores, with widened log range
|
||||
const ratio = reference / value
|
||||
const score = 50 * (1 + Math.log2(Math.max(0.01, ratio)))
|
||||
const score = 50 * (1 + Math.log2(Math.max(0.01, ratio)) / 3)
|
||||
return Math.min(100, Math.max(0, score)) / 100
|
||||
}
|
||||
|
||||
|
|
@ -619,6 +619,7 @@ export class BenchmarkService {
|
|||
const eventsMatch = output.match(/events per second:\s*([\d.]+)/i)
|
||||
const totalTimeMatch = output.match(/total time:\s*([\d.]+)s/i)
|
||||
const totalEventsMatch = output.match(/total number of events:\s*(\d+)/i)
|
||||
logger.debug(`[BenchmarkService] CPU output parsing - events/s: ${eventsMatch?.[1]}, total_time: ${totalTimeMatch?.[1]}, total_events: ${totalEventsMatch?.[1]}`)
|
||||
|
||||
return {
|
||||
events_per_second: eventsMatch ? parseFloat(eventsMatch[1]) : 0,
|
||||
|
|
|
|||
|
|
@ -6,12 +6,14 @@ import transmit from '@adonisjs/transmit/services/main'
|
|||
import { doResumableDownloadWithRetry } from '../utils/downloads.js'
|
||||
import { join } from 'path'
|
||||
import { ZIM_STORAGE_PATH } from '../utils/fs.js'
|
||||
import { KiwixLibraryService } from './kiwix_library_service.js'
|
||||
import { SERVICE_NAMES } from '../../constants/service_names.js'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
// import { readdir } from 'fs/promises'
|
||||
import KVStore from '#models/kv_store'
|
||||
import { BROADCAST_CHANNELS } from '../../constants/broadcast.js'
|
||||
import { KIWIX_LIBRARY_CMD } from '../../constants/kiwix.js'
|
||||
|
||||
@inject()
|
||||
export class DockerService {
|
||||
|
|
@ -19,6 +21,9 @@ export class DockerService {
|
|||
private activeInstallations: Set<string> = new Set()
|
||||
public static NOMAD_NETWORK = 'project-nomad_default'
|
||||
|
||||
private _servicesStatusCache: { data: { service_name: string; status: string }[]; expiresAt: number } | null = null
|
||||
private _servicesStatusInflight: Promise<{ service_name: string; status: string }[]> | null = null
|
||||
|
||||
constructor() {
|
||||
// Support both Linux (production) and Windows (development with Docker Desktop)
|
||||
const isWindows = process.platform === 'win32'
|
||||
|
|
@ -56,6 +61,7 @@ export class DockerService {
|
|||
const dockerContainer = this.docker.getContainer(container.Id)
|
||||
if (action === 'stop') {
|
||||
await dockerContainer.stop()
|
||||
this.invalidateServicesStatusCache()
|
||||
return {
|
||||
success: true,
|
||||
message: `Service ${serviceName} stopped successfully`,
|
||||
|
|
@ -63,7 +69,18 @@ export class DockerService {
|
|||
}
|
||||
|
||||
if (action === 'restart') {
|
||||
if (serviceName === SERVICE_NAMES.KIWIX) {
|
||||
const isLegacy = await this.isKiwixOnLegacyConfig()
|
||||
if (isLegacy) {
|
||||
logger.info('[DockerService] Kiwix on legacy glob config — running migration instead of restart.')
|
||||
await this.migrateKiwixToLibraryMode()
|
||||
this.invalidateServicesStatusCache()
|
||||
return { success: true, message: 'Kiwix migrated to library mode successfully.' }
|
||||
}
|
||||
}
|
||||
|
||||
await dockerContainer.restart()
|
||||
this.invalidateServicesStatusCache()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -80,6 +97,7 @@ export class DockerService {
|
|||
}
|
||||
|
||||
await dockerContainer.start()
|
||||
this.invalidateServicesStatusCache()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -91,7 +109,7 @@ export class DockerService {
|
|||
success: false,
|
||||
message: `Invalid action: ${action}. Use 'start', 'stop', or 'restart'.`,
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
logger.error(`Error starting service ${serviceName}: ${error.message}`)
|
||||
return {
|
||||
success: false,
|
||||
|
|
@ -102,13 +120,37 @@ export class DockerService {
|
|||
|
||||
/**
|
||||
* Fetches the status of all Docker containers related to Nomad services. (those prefixed with 'nomad_')
|
||||
* Results are cached for 5 seconds and concurrent callers share a single in-flight request,
|
||||
* preventing Docker socket congestion during rapid page navigation.
|
||||
*/
|
||||
async getServicesStatus(): Promise<
|
||||
{
|
||||
service_name: string
|
||||
status: string
|
||||
}[]
|
||||
> {
|
||||
async getServicesStatus(): Promise<{ service_name: string; status: string }[]> {
|
||||
const now = Date.now()
|
||||
if (this._servicesStatusCache && now < this._servicesStatusCache.expiresAt) {
|
||||
return this._servicesStatusCache.data
|
||||
}
|
||||
if (this._servicesStatusInflight) return this._servicesStatusInflight
|
||||
|
||||
this._servicesStatusInflight = this._fetchServicesStatus().then((data) => {
|
||||
this._servicesStatusCache = { data, expiresAt: Date.now() + 5000 }
|
||||
this._servicesStatusInflight = null
|
||||
return data
|
||||
}).catch((err) => {
|
||||
this._servicesStatusInflight = null
|
||||
throw err
|
||||
})
|
||||
return this._servicesStatusInflight
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates the services status cache. Call this after any container state change
|
||||
* (start, stop, restart, install, uninstall) so the next read reflects reality.
|
||||
*/
|
||||
invalidateServicesStatusCache() {
|
||||
this._servicesStatusCache = null
|
||||
this._servicesStatusInflight = null
|
||||
}
|
||||
|
||||
private async _fetchServicesStatus(): Promise<{ service_name: string; status: string }[]> {
|
||||
try {
|
||||
const containers = await this.docker.listContainers({ all: true })
|
||||
const containerMap = new Map<string, Docker.ContainerInfo>()
|
||||
|
|
@ -123,7 +165,7 @@ export class DockerService {
|
|||
service_name: name,
|
||||
status: container.State,
|
||||
}))
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
logger.error(`Error fetching services status: ${error.message}`)
|
||||
return []
|
||||
}
|
||||
|
|
@ -140,6 +182,11 @@ export class DockerService {
|
|||
return null
|
||||
}
|
||||
|
||||
if (serviceName === SERVICE_NAMES.OLLAMA) {
|
||||
const remoteUrl = await KVStore.getValue('ai.remoteOllamaUrl')
|
||||
if (remoteUrl) return remoteUrl
|
||||
}
|
||||
|
||||
const service = await Service.query()
|
||||
.where('service_name', serviceName)
|
||||
.andWhere('installed', true)
|
||||
|
|
@ -307,7 +354,7 @@ export class DockerService {
|
|||
`No existing container found, proceeding with installation...`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
logger.warn(`Error during container cleanup: ${error.message}`)
|
||||
this._broadcast(serviceName, 'cleanup-warning', `Warning during cleanup: ${error.message}`)
|
||||
}
|
||||
|
|
@ -326,7 +373,7 @@ export class DockerService {
|
|||
const volume = this.docker.getVolume(vol.Name)
|
||||
await volume.remove({ force: true })
|
||||
this._broadcast(serviceName, 'volume-removed', `Removed volume: ${vol.Name}`)
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
logger.warn(`Failed to remove volume ${vol.Name}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
|
@ -334,7 +381,7 @@ export class DockerService {
|
|||
if (serviceVolumes.length === 0) {
|
||||
this._broadcast(serviceName, 'no-volumes', `No volumes found to clear`)
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
logger.warn(`Error during volume cleanup: ${error.message}`)
|
||||
this._broadcast(
|
||||
serviceName,
|
||||
|
|
@ -347,6 +394,7 @@ export class DockerService {
|
|||
service.installed = false
|
||||
service.installation_status = 'installing'
|
||||
await service.save()
|
||||
this.invalidateServicesStatusCache()
|
||||
|
||||
// Step 5: Recreate the container
|
||||
this._broadcast(serviceName, 'recreating', `Recreating container...`)
|
||||
|
|
@ -362,7 +410,7 @@ export class DockerService {
|
|||
success: true,
|
||||
message: `Service ${serviceName} force reinstall initiated successfully. You can receive updates via server-sent events.`,
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
logger.error(`Force reinstall failed for ${serviceName}: ${error.message}`)
|
||||
await this._cleanupFailedInstallation(serviceName)
|
||||
return {
|
||||
|
|
@ -500,6 +548,15 @@ export class DockerService {
|
|||
}
|
||||
}
|
||||
|
||||
const ollamaEnv: string[] = []
|
||||
if (service.service_name === SERVICE_NAMES.OLLAMA) {
|
||||
ollamaEnv.push('OLLAMA_NO_CLOUD=1')
|
||||
const flashAttentionEnabled = await KVStore.getValue('ai.ollamaFlashAttention')
|
||||
if (flashAttentionEnabled !== false) {
|
||||
ollamaEnv.push('OLLAMA_FLASH_ATTENTION=1')
|
||||
}
|
||||
}
|
||||
|
||||
this._broadcast(
|
||||
service.service_name,
|
||||
'creating',
|
||||
|
|
@ -508,11 +565,16 @@ export class DockerService {
|
|||
const container = await this.docker.createContainer({
|
||||
Image: finalImage,
|
||||
name: service.service_name,
|
||||
Labels: {
|
||||
...(containerConfig?.Labels ?? {}),
|
||||
'com.docker.compose.project': 'project-nomad-managed',
|
||||
'io.project-nomad.managed': 'true',
|
||||
},
|
||||
...(containerConfig?.User && { User: containerConfig.User }),
|
||||
HostConfig: gpuHostConfig,
|
||||
...(containerConfig?.WorkingDir && { WorkingDir: containerConfig.WorkingDir }),
|
||||
...(containerConfig?.ExposedPorts && { ExposedPorts: containerConfig.ExposedPorts }),
|
||||
...(containerConfig?.Env && { Env: containerConfig.Env }),
|
||||
Env: [...(containerConfig?.Env ?? []), ...ollamaEnv],
|
||||
...(service.container_command ? { Cmd: service.container_command.split(' ') } : {}),
|
||||
// Ensure container is attached to the Nomad docker network in production
|
||||
...(process.env.NODE_ENV === 'production' && {
|
||||
|
|
@ -539,6 +601,7 @@ export class DockerService {
|
|||
service.installed = true
|
||||
service.installation_status = 'idle'
|
||||
await service.save()
|
||||
this.invalidateServicesStatusCache()
|
||||
|
||||
// Remove from active installs tracking
|
||||
this.activeInstallations.delete(service.service_name)
|
||||
|
|
@ -564,7 +627,7 @@ export class DockerService {
|
|||
'completed',
|
||||
`Service ${service.service_name} installation completed successfully.`
|
||||
)
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
this._broadcast(
|
||||
service.service_name,
|
||||
'error',
|
||||
|
|
@ -580,7 +643,7 @@ export class DockerService {
|
|||
try {
|
||||
const containers = await this.docker.listContainers({ all: true })
|
||||
return containers.some((container) => container.Names.includes(`/${serviceName}`))
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
logger.error(`Error checking if service container exists: ${error.message}`)
|
||||
return false
|
||||
}
|
||||
|
|
@ -600,7 +663,7 @@ export class DockerService {
|
|||
await dockerContainer.remove({ force: true })
|
||||
|
||||
return { success: true, message: `Service ${serviceName} container removed successfully` }
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
logger.error(`Error removing service container: ${error.message}`)
|
||||
return {
|
||||
success: false,
|
||||
|
|
@ -615,8 +678,8 @@ export class DockerService {
|
|||
* We'll download the lightweight mini Wikipedia Top 100 zim file for this purpose.
|
||||
**/
|
||||
const WIKIPEDIA_ZIM_URL =
|
||||
'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/main/install/wikipedia_en_100_mini_2025-06.zim'
|
||||
const filename = 'wikipedia_en_100_mini_2025-06.zim'
|
||||
'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/main/install/wikipedia_en_100_mini_2026-01.zim'
|
||||
const filename = 'wikipedia_en_100_mini_2026-01.zim'
|
||||
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
|
||||
logger.info(`[DockerService] Kiwix Serve pre-install: Downloading ZIM file to ${filepath}`)
|
||||
|
||||
|
|
@ -648,7 +711,12 @@ export class DockerService {
|
|||
'preinstall',
|
||||
`Downloaded Wikipedia ZIM file to ${filepath}`
|
||||
)
|
||||
} catch (error) {
|
||||
|
||||
// Generate the initial kiwix library XML before the container is created
|
||||
const kiwixLibraryService = new KiwixLibraryService()
|
||||
await kiwixLibraryService.rebuildFromDisk()
|
||||
this._broadcast(SERVICE_NAMES.KIWIX, 'preinstall', 'Generated kiwix library XML.')
|
||||
} catch (error: any) {
|
||||
this._broadcast(
|
||||
SERVICE_NAMES.KIWIX,
|
||||
'preinstall-error',
|
||||
|
|
@ -671,13 +739,121 @@ export class DockerService {
|
|||
await this._removeServiceContainer(serviceName)
|
||||
|
||||
logger.info(`[DockerService] Cleaned up failed installation for ${serviceName}`)
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`[DockerService] Failed to cleanup installation for ${serviceName}: ${error.message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the running kiwix container is using the legacy glob-pattern command
|
||||
* (`*.zim --address=all`) rather than the library-file command. Used to detect containers
|
||||
* that need to be migrated to library mode.
|
||||
*/
|
||||
async isKiwixOnLegacyConfig(): Promise<boolean> {
|
||||
try {
|
||||
const containers = await this.docker.listContainers({ all: true })
|
||||
const info = containers.find((c) => c.Names.includes(`/${SERVICE_NAMES.KIWIX}`))
|
||||
if (!info) return false
|
||||
|
||||
const inspected = await this.docker.getContainer(info.Id).inspect()
|
||||
const cmd: string[] = inspected.Config?.Cmd ?? []
|
||||
return cmd.some((arg) => arg.includes('*.zim'))
|
||||
} catch (err: any) {
|
||||
logger.warn(`[DockerService] Could not inspect kiwix container: ${err.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates the kiwix container from legacy glob mode (`*.zim`) to library mode
|
||||
* (`--library /data/kiwix-library.xml --monitorLibrary`).
|
||||
*
|
||||
* This is a non-destructive recreation: ZIM files and volumes are preserved.
|
||||
* The container is stopped, removed, and recreated with the correct library-mode command.
|
||||
* This function is authoritative: it writes the correct command to the DB itself rather than
|
||||
* trusting the DB to have been pre-updated by a separate migration.
|
||||
*/
|
||||
async migrateKiwixToLibraryMode(): Promise<void> {
|
||||
if (this.activeInstallations.has(SERVICE_NAMES.KIWIX)) {
|
||||
logger.warn('[DockerService] Kiwix migration already in progress, skipping duplicate call.')
|
||||
return
|
||||
}
|
||||
|
||||
this.activeInstallations.add(SERVICE_NAMES.KIWIX)
|
||||
|
||||
try {
|
||||
// Step 1: Build/update the XML from current disk state
|
||||
this._broadcast(SERVICE_NAMES.KIWIX, 'migrating', 'Migrating kiwix to library mode...')
|
||||
const kiwixLibraryService = new KiwixLibraryService()
|
||||
await kiwixLibraryService.rebuildFromDisk()
|
||||
this._broadcast(SERVICE_NAMES.KIWIX, 'migrating', 'Built kiwix library XML from existing ZIM files.')
|
||||
|
||||
// Step 2: Stop and remove old container (leave ZIM volumes intact)
|
||||
const containers = await this.docker.listContainers({ all: true })
|
||||
const containerInfo = containers.find((c) => c.Names.includes(`/${SERVICE_NAMES.KIWIX}`))
|
||||
if (containerInfo) {
|
||||
const oldContainer = this.docker.getContainer(containerInfo.Id)
|
||||
if (containerInfo.State === 'running') {
|
||||
await oldContainer.stop({ t: 10 }).catch((e: any) =>
|
||||
logger.warn(`[DockerService] Kiwix stop warning during migration: ${e.message}`)
|
||||
)
|
||||
}
|
||||
await oldContainer.remove({ force: true }).catch((e: any) =>
|
||||
logger.warn(`[DockerService] Kiwix remove warning during migration: ${e.message}`)
|
||||
)
|
||||
}
|
||||
|
||||
// Step 3: Read the service record and authoritatively set the correct command.
|
||||
// Do NOT rely on prior DB state — we write container_command here so the record
|
||||
// stays consistent regardless of whether the DB migration ran.
|
||||
const service = await Service.query().where('service_name', SERVICE_NAMES.KIWIX).first()
|
||||
if (!service) {
|
||||
throw new Error('Kiwix service record not found in DB during migration')
|
||||
}
|
||||
|
||||
service.container_command = KIWIX_LIBRARY_CMD
|
||||
service.installed = false
|
||||
service.installation_status = 'installing'
|
||||
await service.save()
|
||||
|
||||
const containerConfig = this._parseContainerConfig(service.container_config)
|
||||
|
||||
// Step 4: Recreate container directly (skipping _createContainer to avoid re-downloading
|
||||
// the bootstrap ZIM — ZIM files already exist on disk)
|
||||
this._broadcast(SERVICE_NAMES.KIWIX, 'migrating', 'Recreating kiwix container with library mode config...')
|
||||
const newContainer = await this.docker.createContainer({
|
||||
Image: service.container_image,
|
||||
name: service.service_name,
|
||||
HostConfig: containerConfig?.HostConfig ?? {},
|
||||
...(containerConfig?.ExposedPorts && { ExposedPorts: containerConfig.ExposedPorts }),
|
||||
Cmd: KIWIX_LIBRARY_CMD.split(' '),
|
||||
...(process.env.NODE_ENV === 'production' && {
|
||||
NetworkingConfig: {
|
||||
EndpointsConfig: {
|
||||
[DockerService.NOMAD_NETWORK]: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
await newContainer.start()
|
||||
|
||||
service.installed = true
|
||||
service.installation_status = 'idle'
|
||||
await service.save()
|
||||
this.activeInstallations.delete(SERVICE_NAMES.KIWIX)
|
||||
|
||||
this._broadcast(SERVICE_NAMES.KIWIX, 'migrated', 'Kiwix successfully migrated to library mode.')
|
||||
logger.info('[DockerService] Kiwix migration to library mode complete.')
|
||||
} catch (error: any) {
|
||||
logger.error(`[DockerService] Kiwix migration failed: ${error.message}`)
|
||||
await this._cleanupFailedInstallation(SERVICE_NAMES.KIWIX)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect GPU type and toolkit availability.
|
||||
* Primary: Check Docker runtimes via docker.info() (works from inside containers).
|
||||
|
|
@ -691,9 +867,10 @@ export class DockerService {
|
|||
const runtimes = dockerInfo.Runtimes || {}
|
||||
if ('nvidia' in runtimes) {
|
||||
logger.info('[DockerService] NVIDIA container runtime detected via Docker API')
|
||||
await this._persistGPUType('nvidia')
|
||||
return { type: 'nvidia' }
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
logger.warn(`[DockerService] Could not query Docker info for GPU runtimes: ${error.message}`)
|
||||
}
|
||||
|
||||
|
|
@ -710,7 +887,7 @@ export class DockerService {
|
|||
logger.warn('[DockerService] NVIDIA GPU detected via lspci but NVIDIA Container Toolkit is not installed')
|
||||
return { type: 'none', toolkitMissing: true }
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
// lspci not available (likely inside Docker container), continue
|
||||
}
|
||||
|
||||
|
|
@ -722,20 +899,43 @@ export class DockerService {
|
|||
)
|
||||
if (amdCheck.trim()) {
|
||||
logger.info('[DockerService] AMD GPU detected via lspci')
|
||||
await this._persistGPUType('amd')
|
||||
return { type: 'amd' }
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
// lspci not available, continue
|
||||
}
|
||||
|
||||
// Last resort: check if we previously detected a GPU and it's likely still present.
|
||||
// This handles cases where live detection fails transiently (e.g., Docker daemon
|
||||
// hiccup, runtime temporarily unavailable) but the hardware hasn't changed.
|
||||
try {
|
||||
const savedType = await KVStore.getValue('gpu.type')
|
||||
if (savedType === 'nvidia' || savedType === 'amd') {
|
||||
logger.info(`[DockerService] No GPU detected live, but KV store has '${savedType}' from previous detection. Using saved value.`)
|
||||
return { type: savedType as 'nvidia' | 'amd' }
|
||||
}
|
||||
} catch {
|
||||
// KV store not available, continue
|
||||
}
|
||||
|
||||
logger.info('[DockerService] No GPU detected')
|
||||
return { type: 'none' }
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
logger.warn(`[DockerService] Error detecting GPU type: ${error.message}`)
|
||||
return { type: 'none' }
|
||||
}
|
||||
}
|
||||
|
||||
private async _persistGPUType(type: 'nvidia' | 'amd'): Promise<void> {
|
||||
try {
|
||||
await KVStore.setValue('gpu.type', type)
|
||||
logger.info(`[DockerService] Persisted GPU type '${type}' to KV store`)
|
||||
} catch (error: any) {
|
||||
logger.warn(`[DockerService] Failed to persist GPU type: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover AMD GPU DRI devices dynamically.
|
||||
* Returns an array of device configurations for Docker.
|
||||
|
|
@ -853,6 +1053,45 @@ export class DockerService {
|
|||
this._broadcast(serviceName, 'update-creating', `Creating updated container...`)
|
||||
|
||||
const hostConfig = inspectData.HostConfig || {}
|
||||
|
||||
// Re-run GPU detection for Ollama so updates always reflect the current GPU environment.
|
||||
// This handles cases where the NVIDIA Container Toolkit was installed after the initial
|
||||
// Ollama setup, and ensures DeviceRequests are always built fresh rather than relying on
|
||||
// round-tripping the Docker inspect format back into the create API.
|
||||
let updatedDeviceRequests: any[] | undefined = undefined
|
||||
if (serviceName === SERVICE_NAMES.OLLAMA) {
|
||||
const gpuResult = await this._detectGPUType()
|
||||
|
||||
if (gpuResult.type === 'nvidia') {
|
||||
this._broadcast(
|
||||
serviceName,
|
||||
'update-gpu-config',
|
||||
`NVIDIA container runtime detected. Configuring updated container with GPU support...`
|
||||
)
|
||||
updatedDeviceRequests = [
|
||||
{
|
||||
Driver: 'nvidia',
|
||||
Count: -1,
|
||||
Capabilities: [['gpu']],
|
||||
},
|
||||
]
|
||||
} else if (gpuResult.type === 'amd') {
|
||||
this._broadcast(
|
||||
serviceName,
|
||||
'update-gpu-config',
|
||||
`AMD GPU detected. ROCm GPU acceleration is not yet supported — using CPU-only configuration.`
|
||||
)
|
||||
} else if (gpuResult.toolkitMissing) {
|
||||
this._broadcast(
|
||||
serviceName,
|
||||
'update-gpu-config',
|
||||
`NVIDIA GPU detected but NVIDIA Container Toolkit is not installed. Using CPU-only configuration. Install the toolkit and reinstall AI Assistant for GPU acceleration: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html`
|
||||
)
|
||||
} else {
|
||||
this._broadcast(serviceName, 'update-gpu-config', `No GPU detected. Using CPU-only configuration.`)
|
||||
}
|
||||
}
|
||||
|
||||
const newContainerConfig: any = {
|
||||
Image: newImage,
|
||||
name: serviceName,
|
||||
|
|
@ -865,7 +1104,7 @@ export class DockerService {
|
|||
Binds: hostConfig.Binds || undefined,
|
||||
PortBindings: hostConfig.PortBindings || undefined,
|
||||
RestartPolicy: hostConfig.RestartPolicy || undefined,
|
||||
DeviceRequests: hostConfig.DeviceRequests || undefined,
|
||||
DeviceRequests: serviceName === SERVICE_NAMES.OLLAMA ? updatedDeviceRequests : (hostConfig.DeviceRequests || undefined),
|
||||
Devices: hostConfig.Devices || undefined,
|
||||
},
|
||||
NetworkingConfig: inspectData.NetworkSettings?.Networks
|
||||
|
|
@ -887,7 +1126,7 @@ export class DockerService {
|
|||
let newContainer: any
|
||||
try {
|
||||
newContainer = await this.docker.createContainer(newContainerConfig)
|
||||
} catch (createError) {
|
||||
} catch (createError: any) {
|
||||
// Rollback: rename old container back
|
||||
this._broadcast(serviceName, 'update-rollback', `Failed to create new container: ${createError.message}. Rolling back...`)
|
||||
const rollbackContainer = this.docker.getContainer((await this.docker.listContainers({ all: true })).find((c) => c.Names.includes(`/${oldName}`))!.Id)
|
||||
|
|
@ -960,7 +1199,7 @@ export class DockerService {
|
|||
message: `Update failed: new container did not stay running. Rolled back to previous version.`,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
this.activeInstallations.delete(serviceName)
|
||||
this._broadcast(
|
||||
serviceName,
|
||||
|
|
@ -995,7 +1234,7 @@ export class DockerService {
|
|||
}
|
||||
|
||||
return JSON.parse(toParse)
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to parse container configuration: ${error.message}`)
|
||||
throw new Error(`Invalid container configuration: ${error.message}`)
|
||||
}
|
||||
|
|
@ -1012,7 +1251,7 @@ export class DockerService {
|
|||
|
||||
// Check if any image has a RepoTag that matches the requested image
|
||||
return images.some((image) => image.RepoTags && image.RepoTags.includes(imageName))
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
logger.warn(`Error checking if image exists: ${error.message}`)
|
||||
// If run into an error, assume the image does not exist
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -2,29 +2,68 @@ import { inject } from '@adonisjs/core'
|
|||
import { QueueService } from './queue_service.js'
|
||||
import { RunDownloadJob } from '#jobs/run_download_job'
|
||||
import { DownloadModelJob } from '#jobs/download_model_job'
|
||||
import { DownloadJobWithProgress } from '../../types/downloads.js'
|
||||
import { DownloadJobWithProgress, DownloadProgressData } from '../../types/downloads.js'
|
||||
import { normalize } from 'path'
|
||||
import { deleteFileIfExists } from '../utils/fs.js'
|
||||
|
||||
@inject()
|
||||
export class DownloadService {
|
||||
constructor(private queueService: QueueService) {}
|
||||
|
||||
async listDownloadJobs(filetype?: string): Promise<DownloadJobWithProgress[]> {
|
||||
// Get regular file download jobs (zim, map, etc.)
|
||||
const queue = this.queueService.getQueue(RunDownloadJob.queue)
|
||||
const fileJobs = await queue.getJobs(['waiting', 'active', 'delayed'])
|
||||
private parseProgress(progress: any): { percent: number; downloadedBytes?: number; totalBytes?: number; lastProgressTime?: number } {
|
||||
if (typeof progress === 'object' && progress !== null && 'percent' in progress) {
|
||||
const p = progress as DownloadProgressData
|
||||
return {
|
||||
percent: p.percent,
|
||||
downloadedBytes: p.downloadedBytes,
|
||||
totalBytes: p.totalBytes,
|
||||
lastProgressTime: p.lastProgressTime,
|
||||
}
|
||||
}
|
||||
// Backward compat: plain integer from in-flight jobs during upgrade
|
||||
return { percent: parseInt(String(progress), 10) || 0 }
|
||||
}
|
||||
|
||||
const fileDownloads = fileJobs.map((job) => ({
|
||||
async listDownloadJobs(filetype?: string): Promise<DownloadJobWithProgress[]> {
|
||||
// Get regular file download jobs (zim, map, etc.) — query each state separately so we can
|
||||
// tag each job with its actual BullMQ state rather than guessing from progress data.
|
||||
const queue = this.queueService.getQueue(RunDownloadJob.queue)
|
||||
type FileJobState = 'waiting' | 'active' | 'delayed' | 'failed'
|
||||
|
||||
const [waitingJobs, activeJobs, delayedJobs, failedJobs] = await Promise.all([
|
||||
queue.getJobs(['waiting']),
|
||||
queue.getJobs(['active']),
|
||||
queue.getJobs(['delayed']),
|
||||
queue.getJobs(['failed']),
|
||||
])
|
||||
|
||||
const taggedFileJobs: Array<{ job: (typeof waitingJobs)[0]; state: FileJobState }> = [
|
||||
...waitingJobs.map((j) => ({ job: j, state: 'waiting' as const })),
|
||||
...activeJobs.map((j) => ({ job: j, state: 'active' as const })),
|
||||
...delayedJobs.map((j) => ({ job: j, state: 'delayed' as const })),
|
||||
...failedJobs.map((j) => ({ job: j, state: 'failed' as const })),
|
||||
]
|
||||
|
||||
const fileDownloads = taggedFileJobs.map(({ job, state }) => {
|
||||
const parsed = this.parseProgress(job.progress)
|
||||
return {
|
||||
jobId: job.id!.toString(),
|
||||
url: job.data.url,
|
||||
progress: parseInt(job.progress.toString(), 10),
|
||||
progress: parsed.percent,
|
||||
filepath: normalize(job.data.filepath),
|
||||
filetype: job.data.filetype,
|
||||
}))
|
||||
title: job.data.title || undefined,
|
||||
downloadedBytes: parsed.downloadedBytes,
|
||||
totalBytes: parsed.totalBytes || job.data.totalBytes || undefined,
|
||||
lastProgressTime: parsed.lastProgressTime,
|
||||
status: state,
|
||||
failedReason: job.failedReason || undefined,
|
||||
}
|
||||
})
|
||||
|
||||
// Get Ollama model download jobs
|
||||
const modelQueue = this.queueService.getQueue(DownloadModelJob.queue)
|
||||
const modelJobs = await modelQueue.getJobs(['waiting', 'active', 'delayed'])
|
||||
const modelJobs = await modelQueue.getJobs(['waiting', 'active', 'delayed', 'failed'])
|
||||
|
||||
const modelDownloads = modelJobs.map((job) => ({
|
||||
jobId: job.id!.toString(),
|
||||
|
|
@ -32,6 +71,8 @@ export class DownloadService {
|
|||
progress: parseInt(job.progress.toString(), 10),
|
||||
filepath: job.data.modelName || 'Unknown Model', // Use model name as filepath
|
||||
filetype: 'model',
|
||||
status: (job.failedReason ? 'failed' : 'active') as 'active' | 'failed',
|
||||
failedReason: job.failedReason || undefined,
|
||||
}))
|
||||
|
||||
const allDownloads = [...fileDownloads, ...modelDownloads]
|
||||
|
|
@ -39,7 +80,119 @@ export class DownloadService {
|
|||
// Filter by filetype if specified
|
||||
const filtered = allDownloads.filter((job) => !filetype || job.filetype === filetype)
|
||||
|
||||
// Sort so actively downloading items (progress > 0) appear first, then by progress descending
|
||||
return filtered.sort((a, b) => b.progress - a.progress)
|
||||
// Sort: active downloads first (by progress desc), then failed at the bottom
|
||||
return filtered.sort((a, b) => {
|
||||
if (a.status === 'failed' && b.status !== 'failed') return 1
|
||||
if (a.status !== 'failed' && b.status === 'failed') return -1
|
||||
return b.progress - a.progress
|
||||
})
|
||||
}
|
||||
|
||||
async removeFailedJob(jobId: string): Promise<void> {
|
||||
for (const queueName of [RunDownloadJob.queue, DownloadModelJob.queue]) {
|
||||
const queue = this.queueService.getQueue(queueName)
|
||||
const job = await queue.getJob(jobId)
|
||||
if (job) {
|
||||
try {
|
||||
await job.remove()
|
||||
} catch {
|
||||
// Job may be locked by the worker after cancel. Remove the stale lock and retry.
|
||||
try {
|
||||
const client = await queue.client
|
||||
await client.del(`bull:${queueName}:${jobId}:lock`)
|
||||
await job.remove()
|
||||
} catch {
|
||||
// Last resort: already removed or truly stuck
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async cancelJob(jobId: string): Promise<{ success: boolean; message: string }> {
|
||||
const queue = this.queueService.getQueue(RunDownloadJob.queue)
|
||||
const job = await queue.getJob(jobId)
|
||||
|
||||
if (!job) {
|
||||
// Job already completed (removeOnComplete: true) or doesn't exist
|
||||
return { success: true, message: 'Job not found (may have already completed)' }
|
||||
}
|
||||
|
||||
const filepath = job.data.filepath
|
||||
|
||||
// Signal the worker process to abort the download via Redis
|
||||
await RunDownloadJob.signalCancel(jobId)
|
||||
|
||||
// Also try in-memory abort (works if worker is in same process)
|
||||
RunDownloadJob.abortControllers.get(jobId)?.abort('user-cancel')
|
||||
RunDownloadJob.abortControllers.delete(jobId)
|
||||
|
||||
// Poll for terminal state (up to 4s at 250ms intervals) — cooperates with BullMQ's lifecycle
|
||||
// instead of force-removing an active job and losing the worker's failure/cleanup path.
|
||||
const POLL_INTERVAL_MS = 250
|
||||
const POLL_TIMEOUT_MS = 4000
|
||||
const deadline = Date.now() + POLL_TIMEOUT_MS
|
||||
let reachedTerminal = false
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
|
||||
try {
|
||||
const state = await job.getState()
|
||||
if (state === 'failed' || state === 'completed' || state === 'unknown') {
|
||||
reachedTerminal = true
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
reachedTerminal = true // getState() throws if job is already gone
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!reachedTerminal) {
|
||||
console.warn(`[DownloadService] cancelJob: job ${jobId} did not reach terminal state within timeout, removing anyway`)
|
||||
}
|
||||
|
||||
// Remove the BullMQ job
|
||||
try {
|
||||
await job.remove()
|
||||
} catch {
|
||||
// Lock contention fallback: clear lock and retry once
|
||||
try {
|
||||
const client = await queue.client
|
||||
await client.del(`bull:${RunDownloadJob.queue}:${jobId}:lock`)
|
||||
const updatedJob = await queue.getJob(jobId)
|
||||
if (updatedJob) await updatedJob.remove()
|
||||
} catch {
|
||||
// Best effort - job will be cleaned up on next dismiss attempt
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the partial file from disk
|
||||
if (filepath) {
|
||||
try {
|
||||
await deleteFileIfExists(filepath)
|
||||
// Also try .tmp in case PR #448 staging is merged
|
||||
await deleteFileIfExists(filepath + '.tmp')
|
||||
} catch {
|
||||
// File may not exist yet (waiting job)
|
||||
}
|
||||
}
|
||||
|
||||
// If this was a Wikipedia download, update selection status to failed
|
||||
// (the worker's failed event may not fire if we removed the job first)
|
||||
if (job.data.filetype === 'zim' && job.data.url?.includes('wikipedia_en_')) {
|
||||
try {
|
||||
const { DockerService } = await import('#services/docker_service')
|
||||
const { ZimService } = await import('#services/zim_service')
|
||||
const dockerService = new DockerService()
|
||||
const zimService = new ZimService(dockerService)
|
||||
await zimService.onWikipediaDownloadComplete(job.data.url, false)
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, message: 'Download cancelled and partial file deleted' }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
285
admin/app/services/kiwix_library_service.ts
Normal file
285
admin/app/services/kiwix_library_service.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
import { XMLBuilder, XMLParser } from 'fast-xml-parser'
|
||||
import { readFile, writeFile, rename, readdir } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { Archive } from '@openzim/libzim'
|
||||
import { KIWIX_LIBRARY_XML_PATH, ZIM_STORAGE_PATH, ensureDirectoryExists } from '../utils/fs.js'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
const CONTAINER_DATA_PATH = '/data'
|
||||
const XML_DECLARATION = '<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
|
||||
interface KiwixBook {
|
||||
id: string
|
||||
path: string
|
||||
title: string
|
||||
description?: string
|
||||
language?: string
|
||||
creator?: string
|
||||
publisher?: string
|
||||
name?: string
|
||||
flavour?: string
|
||||
tags?: string
|
||||
faviconMimeType?: string
|
||||
favicon?: string
|
||||
date?: string
|
||||
articleCount?: number
|
||||
mediaCount?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
export class KiwixLibraryService {
|
||||
getLibraryFilePath(): string {
|
||||
return join(process.cwd(), KIWIX_LIBRARY_XML_PATH)
|
||||
}
|
||||
|
||||
containerLibraryPath(): string {
|
||||
return '/data/kiwix-library.xml'
|
||||
}
|
||||
|
||||
private _filenameToTitle(filename: string): string {
|
||||
const withoutExt = filename.endsWith('.zim') ? filename.slice(0, -4) : filename
|
||||
const parts = withoutExt.split('_')
|
||||
// Drop last segment if it looks like a date (YYYY-MM)
|
||||
const lastPart = parts[parts.length - 1]
|
||||
const isDate = /^\d{4}-\d{2}$/.test(lastPart)
|
||||
const titleParts = isDate && parts.length > 1 ? parts.slice(0, -1) : parts
|
||||
return titleParts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads all kiwix-manage-compatible metadata from a ZIM file, including the internal UUID,
|
||||
* rich text fields, and the base64-encoded favicon. Kiwix-serve uses the UUID for OPDS
|
||||
* catalog entries and illustration URLs (/catalog/v2/illustration/{uuid}).
|
||||
*
|
||||
* Returns null on any error so callers can fall back gracefully.
|
||||
*/
|
||||
private _readZimMetadata(zimFilePath: string): Partial<KiwixBook> | null {
|
||||
try {
|
||||
const archive = new Archive(zimFilePath)
|
||||
|
||||
const getMeta = (key: string): string | undefined => {
|
||||
try {
|
||||
return archive.getMetadata(key) || undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
let favicon: string | undefined
|
||||
let faviconMimeType: string | undefined
|
||||
try {
|
||||
if (archive.illustrationSizes.size > 0) {
|
||||
const size = archive.illustrationSizes.has(48)
|
||||
? 48
|
||||
: ([...archive.illustrationSizes][0] as number)
|
||||
const item = archive.getIllustrationItem(size)
|
||||
favicon = item.data.data.toString('base64')
|
||||
faviconMimeType = item.mimetype || undefined
|
||||
}
|
||||
} catch {
|
||||
// ZIM has no illustration — that's fine
|
||||
}
|
||||
|
||||
const rawFilesize =
|
||||
typeof archive.filesize === 'bigint' ? Number(archive.filesize) : archive.filesize
|
||||
|
||||
return {
|
||||
id: archive.uuid || undefined,
|
||||
title: getMeta('Title'),
|
||||
description: getMeta('Description'),
|
||||
language: getMeta('Language'),
|
||||
creator: getMeta('Creator'),
|
||||
publisher: getMeta('Publisher'),
|
||||
name: getMeta('Name'),
|
||||
flavour: getMeta('Flavour'),
|
||||
tags: getMeta('Tags'),
|
||||
date: getMeta('Date'),
|
||||
articleCount: archive.articleCount,
|
||||
mediaCount: archive.mediaCount,
|
||||
size: Math.floor(rawFilesize / 1024),
|
||||
favicon,
|
||||
faviconMimeType,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private _buildXml(books: KiwixBook[]): string {
|
||||
const builder = new XMLBuilder({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
format: true,
|
||||
suppressEmptyNode: false,
|
||||
})
|
||||
|
||||
const obj: Record<string, any> = {
|
||||
library: {
|
||||
'@_version': '20110515',
|
||||
...(books.length > 0 && {
|
||||
book: books.map((b) => ({
|
||||
'@_id': b.id,
|
||||
'@_path': b.path,
|
||||
'@_title': b.title,
|
||||
...(b.description !== undefined && { '@_description': b.description }),
|
||||
...(b.language !== undefined && { '@_language': b.language }),
|
||||
...(b.creator !== undefined && { '@_creator': b.creator }),
|
||||
...(b.publisher !== undefined && { '@_publisher': b.publisher }),
|
||||
...(b.name !== undefined && { '@_name': b.name }),
|
||||
...(b.flavour !== undefined && { '@_flavour': b.flavour }),
|
||||
...(b.tags !== undefined && { '@_tags': b.tags }),
|
||||
...(b.faviconMimeType !== undefined && { '@_faviconMimeType': b.faviconMimeType }),
|
||||
...(b.favicon !== undefined && { '@_favicon': b.favicon }),
|
||||
...(b.date !== undefined && { '@_date': b.date }),
|
||||
...(b.articleCount !== undefined && { '@_articleCount': b.articleCount }),
|
||||
...(b.mediaCount !== undefined && { '@_mediaCount': b.mediaCount }),
|
||||
...(b.size !== undefined && { '@_size': b.size }),
|
||||
})),
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
return XML_DECLARATION + builder.build(obj)
|
||||
}
|
||||
|
||||
private async _atomicWrite(content: string): Promise<void> {
|
||||
const filePath = this.getLibraryFilePath()
|
||||
const tmpPath = `${filePath}.tmp.${randomUUID()}`
|
||||
await writeFile(tmpPath, content, 'utf-8')
|
||||
await rename(tmpPath, filePath)
|
||||
}
|
||||
|
||||
private _parseExistingBooks(xmlContent: string): KiwixBook[] {
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
isArray: (name) => name === 'book',
|
||||
})
|
||||
|
||||
const parsed = parser.parse(xmlContent)
|
||||
const books: any[] = parsed?.library?.book ?? []
|
||||
|
||||
return books
|
||||
.map((b) => ({
|
||||
id: b['@_id'] ?? '',
|
||||
path: b['@_path'] ?? '',
|
||||
title: b['@_title'] ?? '',
|
||||
description: b['@_description'],
|
||||
language: b['@_language'],
|
||||
creator: b['@_creator'],
|
||||
publisher: b['@_publisher'],
|
||||
name: b['@_name'],
|
||||
flavour: b['@_flavour'],
|
||||
tags: b['@_tags'],
|
||||
faviconMimeType: b['@_faviconMimeType'],
|
||||
favicon: b['@_favicon'],
|
||||
date: b['@_date'],
|
||||
articleCount:
|
||||
b['@_articleCount'] !== undefined ? Number(b['@_articleCount']) : undefined,
|
||||
mediaCount: b['@_mediaCount'] !== undefined ? Number(b['@_mediaCount']) : undefined,
|
||||
size: b['@_size'] !== undefined ? Number(b['@_size']) : undefined,
|
||||
}))
|
||||
.filter((b) => b.id && b.path)
|
||||
}
|
||||
|
||||
async rebuildFromDisk(opts?: { excludeFilenames?: string[] }): Promise<void> {
|
||||
const dirPath = join(process.cwd(), ZIM_STORAGE_PATH)
|
||||
await ensureDirectoryExists(dirPath)
|
||||
|
||||
let entries: string[] = []
|
||||
try {
|
||||
entries = await readdir(dirPath)
|
||||
} catch {
|
||||
entries = []
|
||||
}
|
||||
|
||||
const excludeSet = new Set(opts?.excludeFilenames ?? [])
|
||||
const zimFiles = entries.filter((name) => name.endsWith('.zim') && !excludeSet.has(name))
|
||||
|
||||
const books: KiwixBook[] = zimFiles.map((filename) => {
|
||||
const meta = this._readZimMetadata(join(dirPath, filename))
|
||||
const containerPath = `${CONTAINER_DATA_PATH}/${filename}`
|
||||
return {
|
||||
...meta,
|
||||
// Override fields that must be derived locally, not from ZIM metadata
|
||||
id: meta?.id ?? filename.slice(0, -4),
|
||||
path: containerPath,
|
||||
title: meta?.title ?? this._filenameToTitle(filename),
|
||||
}
|
||||
})
|
||||
|
||||
const xml = this._buildXml(books)
|
||||
await this._atomicWrite(xml)
|
||||
logger.info(`[KiwixLibraryService] Rebuilt library XML with ${books.length} book(s).`)
|
||||
}
|
||||
|
||||
async addBook(filename: string): Promise<void> {
|
||||
const zimFilename = filename.endsWith('.zim') ? filename : `${filename}.zim`
|
||||
const containerPath = `${CONTAINER_DATA_PATH}/${zimFilename}`
|
||||
|
||||
const filePath = this.getLibraryFilePath()
|
||||
let existingBooks: KiwixBook[] = []
|
||||
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf-8')
|
||||
existingBooks = this._parseExistingBooks(content)
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ENOENT') {
|
||||
// XML doesn't exist yet — rebuild from disk; the completed download is already there
|
||||
await this.rebuildFromDisk()
|
||||
return
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
if (existingBooks.some((b) => b.path === containerPath)) {
|
||||
logger.info(`[KiwixLibraryService] ${zimFilename} already in library, skipping.`)
|
||||
return
|
||||
}
|
||||
|
||||
const fullPath = join(process.cwd(), ZIM_STORAGE_PATH, zimFilename)
|
||||
const meta = this._readZimMetadata(fullPath)
|
||||
|
||||
existingBooks.push({
|
||||
...meta,
|
||||
id: meta?.id ?? zimFilename.slice(0, -4),
|
||||
path: containerPath,
|
||||
title: meta?.title ?? this._filenameToTitle(zimFilename),
|
||||
})
|
||||
|
||||
const xml = this._buildXml(existingBooks)
|
||||
await this._atomicWrite(xml)
|
||||
logger.info(`[KiwixLibraryService] Added ${zimFilename} to library XML.`)
|
||||
}
|
||||
|
||||
async removeBook(filename: string): Promise<void> {
|
||||
const zimFilename = filename.endsWith('.zim') ? filename : `${filename}.zim`
|
||||
const containerPath = `${CONTAINER_DATA_PATH}/${zimFilename}`
|
||||
|
||||
const filePath = this.getLibraryFilePath()
|
||||
let existingBooks: KiwixBook[] = []
|
||||
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf-8')
|
||||
existingBooks = this._parseExistingBooks(content)
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ENOENT') {
|
||||
logger.warn(`[KiwixLibraryService] Library XML not found, nothing to remove.`)
|
||||
return
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
const filtered = existingBooks.filter((b) => b.path !== containerPath)
|
||||
|
||||
if (filtered.length === existingBooks.length) {
|
||||
logger.info(`[KiwixLibraryService] ${zimFilename} not found in library, nothing to remove.`)
|
||||
return
|
||||
}
|
||||
|
||||
const xml = this._buildXml(filtered)
|
||||
await this._atomicWrite(xml)
|
||||
logger.info(`[KiwixLibraryService] Removed ${zimFilename} from library XML.`)
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,16 @@ import InstalledResource from '#models/installed_resource'
|
|||
import { CollectionManifestService } from './collection_manifest_service.js'
|
||||
import type { CollectionWithStatus, MapsSpec } from '../../types/collections.js'
|
||||
|
||||
const PROTOMAPS_BUILDS_METADATA_URL = 'https://build-metadata.protomaps.dev/builds.json'
|
||||
const PROTOMAPS_BUILD_BASE_URL = 'https://build.protomaps.com'
|
||||
|
||||
export interface ProtomapsBuildInfo {
|
||||
url: string
|
||||
date: string
|
||||
size: number
|
||||
key: string
|
||||
}
|
||||
|
||||
const BASE_ASSETS_MIME_TYPES = [
|
||||
'application/gzip',
|
||||
'application/x-gzip',
|
||||
|
|
@ -109,7 +119,7 @@ export class MapService implements IMapService {
|
|||
const downloadFilenames: string[] = []
|
||||
|
||||
for (const resource of toDownload) {
|
||||
const existing = await RunDownloadJob.getByUrl(resource.url)
|
||||
const existing = await RunDownloadJob.getActiveByUrl(resource.url)
|
||||
if (existing) {
|
||||
logger.warn(`[MapService] Download already in progress for URL ${resource.url}, skipping.`)
|
||||
continue
|
||||
|
|
@ -131,6 +141,7 @@ export class MapService implements IMapService {
|
|||
allowedMimeTypes: PMTILES_MIME_TYPES,
|
||||
forceNew: true,
|
||||
filetype: 'map',
|
||||
title: (resource as any).title || undefined,
|
||||
resourceMetadata: {
|
||||
resource_id: resource.id,
|
||||
version: resource.version,
|
||||
|
|
@ -179,7 +190,7 @@ export class MapService implements IMapService {
|
|||
throw new Error(`Invalid PMTiles file URL: ${url}. URL must end with .pmtiles`)
|
||||
}
|
||||
|
||||
const existing = await RunDownloadJob.getByUrl(url)
|
||||
const existing = await RunDownloadJob.getActiveByUrl(url)
|
||||
if (existing) {
|
||||
throw new Error(`Download already in progress for URL ${url}`)
|
||||
}
|
||||
|
|
@ -260,7 +271,7 @@ export class MapService implements IMapService {
|
|||
}
|
||||
}
|
||||
|
||||
async generateStylesJSON(host: string | null = null): Promise<BaseStylesFile> {
|
||||
async generateStylesJSON(host: string | null = null, protocol: string = 'http'): Promise<BaseStylesFile> {
|
||||
if (!(await this.checkBaseAssetsExist())) {
|
||||
throw new Error('Base map assets are missing from storage/maps')
|
||||
}
|
||||
|
|
@ -281,8 +292,8 @@ export class MapService implements IMapService {
|
|||
* e.g. user is accessing from "example.com", but we would by default generate "localhost:8080/..." so maps would
|
||||
* fail to load.
|
||||
*/
|
||||
const sources = this.generateSourcesArray(host, regions)
|
||||
const baseUrl = this.getPublicFileBaseUrl(host, this.basemapsAssetsDir)
|
||||
const sources = this.generateSourcesArray(host, regions, protocol)
|
||||
const baseUrl = this.getPublicFileBaseUrl(host, this.basemapsAssetsDir, protocol)
|
||||
|
||||
const styles = await this.generateStylesFile(
|
||||
rawStyles,
|
||||
|
|
@ -342,9 +353,9 @@ export class MapService implements IMapService {
|
|||
return await listDirectoryContentsRecursive(this.baseDirPath)
|
||||
}
|
||||
|
||||
private generateSourcesArray(host: string | null, regions: FileEntry[]): BaseStylesFile['sources'][] {
|
||||
private generateSourcesArray(host: string | null, regions: FileEntry[], protocol: string = 'http'): BaseStylesFile['sources'][] {
|
||||
const sources: BaseStylesFile['sources'][] = []
|
||||
const baseUrl = this.getPublicFileBaseUrl(host, 'pmtiles')
|
||||
const baseUrl = this.getPublicFileBaseUrl(host, 'pmtiles', protocol)
|
||||
|
||||
for (const region of regions) {
|
||||
if (region.type === 'file' && region.name.endsWith('.pmtiles')) {
|
||||
|
|
@ -398,6 +409,76 @@ export class MapService implements IMapService {
|
|||
return template
|
||||
}
|
||||
|
||||
async getGlobalMapInfo(): Promise<ProtomapsBuildInfo> {
|
||||
const { default: axios } = await import('axios')
|
||||
const response = await axios.get(PROTOMAPS_BUILDS_METADATA_URL, { timeout: 15000 })
|
||||
const builds = response.data as Array<{ key: string; size: number }>
|
||||
|
||||
if (!builds || builds.length === 0) {
|
||||
throw new Error('No protomaps builds found')
|
||||
}
|
||||
|
||||
// Latest build first
|
||||
const sorted = builds.sort((a, b) => b.key.localeCompare(a.key))
|
||||
const latest = sorted[0]
|
||||
|
||||
const dateStr = latest.key.replace('.pmtiles', '')
|
||||
const date = `${dateStr.slice(0, 4)}-${dateStr.slice(4, 6)}-${dateStr.slice(6, 8)}`
|
||||
|
||||
return {
|
||||
url: `${PROTOMAPS_BUILD_BASE_URL}/${latest.key}`,
|
||||
date,
|
||||
size: latest.size,
|
||||
key: latest.key,
|
||||
}
|
||||
}
|
||||
|
||||
async downloadGlobalMap(): Promise<{ filename: string; jobId?: string }> {
|
||||
const info = await this.getGlobalMapInfo()
|
||||
|
||||
const existing = await RunDownloadJob.getByUrl(info.url)
|
||||
if (existing) {
|
||||
throw new Error(`Download already in progress for URL ${info.url}`)
|
||||
}
|
||||
|
||||
const basePath = resolve(join(this.baseDirPath, 'pmtiles'))
|
||||
const filepath = resolve(join(basePath, info.key))
|
||||
|
||||
// Prevent path traversal — resolved path must stay within the storage directory
|
||||
if (!filepath.startsWith(basePath + sep)) {
|
||||
throw new Error('Invalid filename')
|
||||
}
|
||||
|
||||
// First, ensure base assets are present - the global map depends on them
|
||||
const baseAssetsExist = await this.ensureBaseAssets()
|
||||
if (!baseAssetsExist) {
|
||||
throw new Error(
|
||||
'Base map assets are missing and could not be downloaded. Please check your connection and try again.'
|
||||
)
|
||||
}
|
||||
|
||||
// forceNew: false so retries resume partial downloads
|
||||
const result = await RunDownloadJob.dispatch({
|
||||
url: info.url,
|
||||
filepath,
|
||||
timeout: 30000,
|
||||
allowedMimeTypes: PMTILES_MIME_TYPES,
|
||||
forceNew: false,
|
||||
filetype: 'map',
|
||||
})
|
||||
|
||||
if (!result.job) {
|
||||
throw new Error('Failed to dispatch download job')
|
||||
}
|
||||
|
||||
logger.info(`[MapService] Dispatched global map download job ${result.job.id}`)
|
||||
|
||||
return {
|
||||
filename: info.key,
|
||||
jobId: result.job?.id,
|
||||
}
|
||||
}
|
||||
|
||||
async delete(file: string): Promise<void> {
|
||||
let fileName = file
|
||||
if (!fileName.endsWith('.pmtiles')) {
|
||||
|
|
@ -430,10 +511,20 @@ export class MapService implements IMapService {
|
|||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Gets the appropriate public URL for a map asset depending on environment
|
||||
/**
|
||||
* Gets the appropriate public URL for a map asset depending on environment. The host and protocol that the user
|
||||
* is accessing the maps from must match the host and protocol used in the generated URLs, otherwise maps will fail to load.
|
||||
* If you make changes to this function, you need to ensure it handles all the following cases correctly:
|
||||
* - No host provided (should default to localhost or env URL)
|
||||
* - Host provided as full URL (e.g. "http://example.com:8080")
|
||||
* - Host provided as host:port (e.g. "example.com:8080")
|
||||
* - Host provided as bare hostname (e.g. "example.com")
|
||||
* @param specifiedHost - the host as provided by the user/request, can be null or in various formats (full URL, host:port, bare hostname)
|
||||
* @param childPath - the path to append to the base URL (e.g. "basemaps-assets", "pmtiles")
|
||||
* @param protocol - the protocol to use in the generated URL (e.g. "http", "https"), defaults to "http"
|
||||
* @returns the public URL for the map asset
|
||||
*/
|
||||
private getPublicFileBaseUrl(specifiedHost: string | null, childPath: string): string {
|
||||
private getPublicFileBaseUrl(specifiedHost: string | null, childPath: string, protocol: string = 'http'): string {
|
||||
function getHost() {
|
||||
try {
|
||||
const localUrlRaw = env.get('URL')
|
||||
|
|
@ -446,8 +537,25 @@ export class MapService implements IMapService {
|
|||
}
|
||||
}
|
||||
|
||||
const host = specifiedHost || getHost()
|
||||
const withProtocol = host.startsWith('http') ? host : `http://${host}`
|
||||
function specifiedHostOrDefault() {
|
||||
if (specifiedHost === null) {
|
||||
return getHost()
|
||||
}
|
||||
// Try as a full URL first (e.g. "http://example.com:8080")
|
||||
try {
|
||||
const specifiedUrl = new URL(specifiedHost)
|
||||
if (specifiedUrl.host) return specifiedUrl.host
|
||||
} catch {}
|
||||
// Try as a bare host or host:port (e.g. "nomad-box:8080", "192.168.1.1:8080", "example.com")
|
||||
try {
|
||||
const specifiedUrl = new URL(`http://${specifiedHost}`)
|
||||
if (specifiedUrl.host) return specifiedUrl.host
|
||||
} catch {}
|
||||
return getHost()
|
||||
}
|
||||
|
||||
const host = specifiedHostOrDefault();
|
||||
const withProtocol = `${protocol}://${host}`
|
||||
const baseUrlPath =
|
||||
process.env.NODE_ENV === 'production' ? childPath : urlJoin(this.mapStoragePath, childPath)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { inject } from '@adonisjs/core'
|
||||
import { ChatRequest, Ollama } from 'ollama'
|
||||
import OpenAI from 'openai'
|
||||
import type { ChatCompletionChunk, ChatCompletionMessageParam } from 'openai/resources/chat/completions.js'
|
||||
import type { Stream } from 'openai/streaming.js'
|
||||
import { NomadOllamaModel } from '../../types/ollama.js'
|
||||
import { FALLBACK_RECOMMENDED_OLLAMA_MODELS } from '../../constants/ollama.js'
|
||||
import fs from 'node:fs/promises'
|
||||
|
|
@ -13,51 +15,93 @@ import Fuse, { IFuseOptions } from 'fuse.js'
|
|||
import { BROADCAST_CHANNELS } from '../../constants/broadcast.js'
|
||||
import env from '#start/env'
|
||||
import { NOMAD_API_DEFAULT_BASE_URL } from '../../constants/misc.js'
|
||||
import KVStore from '#models/kv_store'
|
||||
|
||||
const NOMAD_MODELS_API_PATH = '/api/v1/ollama/models'
|
||||
const MODELS_CACHE_FILE = path.join(process.cwd(), 'storage', 'ollama-models-cache.json')
|
||||
const CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000 // 24 hours
|
||||
|
||||
export type NomadInstalledModel = {
|
||||
name: string
|
||||
size: number
|
||||
digest?: string
|
||||
details?: Record<string, any>
|
||||
}
|
||||
|
||||
export type NomadChatResponse = {
|
||||
message: { content: string; thinking?: string }
|
||||
done: boolean
|
||||
model: string
|
||||
}
|
||||
|
||||
export type NomadChatStreamChunk = {
|
||||
message: { content: string; thinking?: string }
|
||||
done: boolean
|
||||
}
|
||||
|
||||
type ChatInput = {
|
||||
model: string
|
||||
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>
|
||||
think?: boolean | 'medium'
|
||||
stream?: boolean
|
||||
numCtx?: number
|
||||
}
|
||||
|
||||
@inject()
|
||||
export class OllamaService {
|
||||
private ollama: Ollama | null = null
|
||||
private ollamaInitPromise: Promise<void> | null = null
|
||||
private openai: OpenAI | null = null
|
||||
private baseUrl: string | null = null
|
||||
private initPromise: Promise<void> | null = null
|
||||
private isOllamaNative: boolean | null = null
|
||||
|
||||
constructor() {}
|
||||
|
||||
private async _initializeOllamaClient() {
|
||||
if (!this.ollamaInitPromise) {
|
||||
this.ollamaInitPromise = (async () => {
|
||||
private async _initialize() {
|
||||
if (!this.initPromise) {
|
||||
this.initPromise = (async () => {
|
||||
// Check KVStore for a custom base URL (remote Ollama, LM Studio, llama.cpp, etc.)
|
||||
const customUrl = (await KVStore.getValue('ai.remoteOllamaUrl')) as string | null
|
||||
if (customUrl && customUrl.trim()) {
|
||||
this.baseUrl = customUrl.trim().replace(/\/$/, '')
|
||||
} else {
|
||||
// Fall back to the local Ollama container managed by Docker
|
||||
const dockerService = new (await import('./docker_service.js')).DockerService()
|
||||
const qdrantUrl = await dockerService.getServiceURL(SERVICE_NAMES.OLLAMA)
|
||||
if (!qdrantUrl) {
|
||||
const ollamaUrl = await dockerService.getServiceURL(SERVICE_NAMES.OLLAMA)
|
||||
if (!ollamaUrl) {
|
||||
throw new Error('Ollama service is not installed or running.')
|
||||
}
|
||||
this.ollama = new Ollama({ host: qdrantUrl })
|
||||
this.baseUrl = ollamaUrl.trim().replace(/\/$/, '')
|
||||
}
|
||||
|
||||
this.openai = new OpenAI({
|
||||
apiKey: 'nomad', // Required by SDK; not validated by Ollama/LM Studio/llama.cpp
|
||||
baseURL: `${this.baseUrl}/v1`,
|
||||
})
|
||||
})()
|
||||
}
|
||||
return this.ollamaInitPromise
|
||||
return this.initPromise
|
||||
}
|
||||
|
||||
private async _ensureDependencies() {
|
||||
if (!this.ollama) {
|
||||
await this._initializeOllamaClient()
|
||||
if (!this.openai) {
|
||||
await this._initialize()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a model from the Ollama service with progress tracking. Where possible,
|
||||
* one should dispatch a background job instead of calling this method directly to avoid long blocking.
|
||||
* @param model Model name to download
|
||||
* @returns Success status and message
|
||||
* Downloads a model from Ollama with progress tracking. Only works with Ollama backends.
|
||||
* Use dispatchModelDownload() for background job processing where possible.
|
||||
*/
|
||||
async downloadModel(model: string, progressCallback?: (percent: number) => void): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
async downloadModel(
|
||||
model: string,
|
||||
progressCallback?: (percent: number) => void
|
||||
): Promise<{ success: boolean; message: string; retryable?: boolean }> {
|
||||
await this._ensureDependencies()
|
||||
if (!this.ollama) {
|
||||
throw new Error('Ollama client is not initialized.')
|
||||
if (!this.baseUrl) {
|
||||
return { success: false, message: 'AI service is not initialized.' }
|
||||
}
|
||||
|
||||
try {
|
||||
// See if model is already installed
|
||||
const installedModels = await this.getModels()
|
||||
if (installedModels && installedModels.some((m) => m.name === model)) {
|
||||
|
|
@ -65,32 +109,67 @@ export class OllamaService {
|
|||
return { success: true, message: 'Model is already installed.' }
|
||||
}
|
||||
|
||||
// Returns AbortableAsyncIterator<ProgressResponse>
|
||||
const downloadStream = await this.ollama.pull({
|
||||
model,
|
||||
stream: true,
|
||||
// Model pulling is an Ollama-only operation. Non-Ollama backends (LM Studio, llama.cpp, etc.)
|
||||
// return HTTP 200 for unknown endpoints, so the pull would appear to succeed but do nothing.
|
||||
if (this.isOllamaNative === false) {
|
||||
logger.warn(
|
||||
`[OllamaService] Non-Ollama backend detected — skipping model pull for "${model}". Load the model manually in your AI host.`
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
message: `Model "${model}" is not available in your AI host. Please load it manually (model pulling is only supported for Ollama backends).`,
|
||||
}
|
||||
}
|
||||
|
||||
// Stream pull via Ollama native API
|
||||
const pullResponse = await axios.post(
|
||||
`${this.baseUrl}/api/pull`,
|
||||
{ model, stream: true },
|
||||
{ responseType: 'stream', timeout: 0 }
|
||||
)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let buffer = ''
|
||||
pullResponse.data.on('data', (chunk: Buffer) => {
|
||||
buffer += chunk.toString()
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
try {
|
||||
const parsed = JSON.parse(line)
|
||||
if (parsed.completed && parsed.total) {
|
||||
const percent = parseFloat(((parsed.completed / parsed.total) * 100).toFixed(2))
|
||||
this.broadcastDownloadProgress(model, percent)
|
||||
if (progressCallback) progressCallback(percent)
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors on partial lines
|
||||
}
|
||||
}
|
||||
})
|
||||
pullResponse.data.on('end', resolve)
|
||||
pullResponse.data.on('error', reject)
|
||||
})
|
||||
|
||||
for await (const chunk of downloadStream) {
|
||||
if (chunk.completed && chunk.total) {
|
||||
const percent = ((chunk.completed / chunk.total) * 100).toFixed(2)
|
||||
const percentNum = parseFloat(percent)
|
||||
|
||||
this.broadcastDownloadProgress(model, percentNum)
|
||||
if (progressCallback) {
|
||||
progressCallback(percentNum)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[OllamaService] Model "${model}" downloaded successfully.`)
|
||||
return { success: true, message: 'Model downloaded successfully.' }
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
logger.error(
|
||||
`[OllamaService] Failed to download model "${model}": ${error instanceof Error ? error.message : error
|
||||
}`
|
||||
`[OllamaService] Failed to download model "${model}": ${errorMessage}`
|
||||
)
|
||||
return { success: false, message: 'Failed to download model.' }
|
||||
|
||||
// Check for version mismatch (Ollama 412 response)
|
||||
const isVersionMismatch = errorMessage.includes('newer version of Ollama')
|
||||
const userMessage = isVersionMismatch
|
||||
? 'This model requires a newer version of Ollama. Please update AI Assistant from the Apps page.'
|
||||
: `Failed to download model: ${errorMessage}`
|
||||
|
||||
// Broadcast failure to connected clients so UI can show the error
|
||||
this.broadcastDownloadError(model, userMessage)
|
||||
|
||||
return { success: false, message: userMessage, retryable: !isVersionMismatch }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -118,88 +197,257 @@ export class OllamaService {
|
|||
}
|
||||
}
|
||||
|
||||
public async getClient() {
|
||||
public async chat(chatRequest: ChatInput): Promise<NomadChatResponse> {
|
||||
await this._ensureDependencies()
|
||||
return this.ollama!
|
||||
if (!this.openai) {
|
||||
throw new Error('AI client is not initialized.')
|
||||
}
|
||||
|
||||
public async chat(chatRequest: ChatRequest & { stream?: boolean }) {
|
||||
await this._ensureDependencies()
|
||||
if (!this.ollama) {
|
||||
throw new Error('Ollama client is not initialized.')
|
||||
}
|
||||
return await this.ollama.chat({
|
||||
...chatRequest,
|
||||
const params: any = {
|
||||
model: chatRequest.model,
|
||||
messages: chatRequest.messages as ChatCompletionMessageParam[],
|
||||
stream: false,
|
||||
})
|
||||
}
|
||||
if (chatRequest.think) {
|
||||
params.think = chatRequest.think
|
||||
}
|
||||
if (chatRequest.numCtx) {
|
||||
params.num_ctx = chatRequest.numCtx
|
||||
}
|
||||
|
||||
public async chatStream(chatRequest: ChatRequest) {
|
||||
await this._ensureDependencies()
|
||||
if (!this.ollama) {
|
||||
throw new Error('Ollama client is not initialized.')
|
||||
const response = await this.openai.chat.completions.create(params)
|
||||
const choice = response.choices[0]
|
||||
|
||||
return {
|
||||
message: {
|
||||
content: choice.message.content ?? '',
|
||||
thinking: (choice.message as any).thinking ?? undefined,
|
||||
},
|
||||
done: true,
|
||||
model: response.model,
|
||||
}
|
||||
return await this.ollama.chat({
|
||||
...chatRequest,
|
||||
}
|
||||
|
||||
public async chatStream(chatRequest: ChatInput): Promise<AsyncIterable<NomadChatStreamChunk>> {
|
||||
await this._ensureDependencies()
|
||||
if (!this.openai) {
|
||||
throw new Error('AI client is not initialized.')
|
||||
}
|
||||
|
||||
const params: any = {
|
||||
model: chatRequest.model,
|
||||
messages: chatRequest.messages as ChatCompletionMessageParam[],
|
||||
stream: true,
|
||||
})
|
||||
}
|
||||
if (chatRequest.think) {
|
||||
params.think = chatRequest.think
|
||||
}
|
||||
if (chatRequest.numCtx) {
|
||||
params.num_ctx = chatRequest.numCtx
|
||||
}
|
||||
|
||||
const stream = (await this.openai.chat.completions.create(params)) as unknown as Stream<ChatCompletionChunk>
|
||||
|
||||
// Returns how many trailing chars of `text` could be the start of `tag`
|
||||
function partialTagSuffix(tag: string, text: string): number {
|
||||
for (let len = Math.min(tag.length - 1, text.length); len >= 1; len--) {
|
||||
if (text.endsWith(tag.slice(0, len))) return len
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
async function* normalize(): AsyncGenerator<NomadChatStreamChunk> {
|
||||
// Stateful parser for <think>...</think> tags that may be split across chunks.
|
||||
// Ollama provides thinking natively via delta.thinking; OpenAI-compatible backends
|
||||
// (LM Studio, llama.cpp, etc.) embed them inline in delta.content.
|
||||
let tagBuffer = ''
|
||||
let inThink = false
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta
|
||||
const nativeThinking: string = (delta as any)?.thinking ?? ''
|
||||
const rawContent: string = delta?.content ?? ''
|
||||
|
||||
// Parse <think> tags out of the content stream
|
||||
tagBuffer += rawContent
|
||||
let parsedContent = ''
|
||||
let parsedThinking = ''
|
||||
|
||||
while (tagBuffer.length > 0) {
|
||||
if (inThink) {
|
||||
const closeIdx = tagBuffer.indexOf('</think>')
|
||||
if (closeIdx !== -1) {
|
||||
parsedThinking += tagBuffer.slice(0, closeIdx)
|
||||
tagBuffer = tagBuffer.slice(closeIdx + 8)
|
||||
inThink = false
|
||||
} else {
|
||||
const hold = partialTagSuffix('</think>', tagBuffer)
|
||||
parsedThinking += tagBuffer.slice(0, tagBuffer.length - hold)
|
||||
tagBuffer = tagBuffer.slice(tagBuffer.length - hold)
|
||||
break
|
||||
}
|
||||
} else {
|
||||
const openIdx = tagBuffer.indexOf('<think>')
|
||||
if (openIdx !== -1) {
|
||||
parsedContent += tagBuffer.slice(0, openIdx)
|
||||
tagBuffer = tagBuffer.slice(openIdx + 7)
|
||||
inThink = true
|
||||
} else {
|
||||
const hold = partialTagSuffix('<think>', tagBuffer)
|
||||
parsedContent += tagBuffer.slice(0, tagBuffer.length - hold)
|
||||
tagBuffer = tagBuffer.slice(tagBuffer.length - hold)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
yield {
|
||||
message: {
|
||||
content: parsedContent,
|
||||
thinking: nativeThinking + parsedThinking,
|
||||
},
|
||||
done: chunk.choices[0]?.finish_reason !== null && chunk.choices[0]?.finish_reason !== undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return normalize()
|
||||
}
|
||||
|
||||
public async checkModelHasThinking(modelName: string): Promise<boolean> {
|
||||
await this._ensureDependencies()
|
||||
if (!this.ollama) {
|
||||
throw new Error('Ollama client is not initialized.')
|
||||
if (!this.baseUrl) return false
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${this.baseUrl}/api/show`,
|
||||
{ model: modelName },
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
return Array.isArray(response.data?.capabilities) && response.data.capabilities.includes('thinking')
|
||||
} catch {
|
||||
// Non-Ollama backends don't expose /api/show — assume no thinking support
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const modelInfo = await this.ollama.show({
|
||||
model: modelName,
|
||||
})
|
||||
|
||||
return modelInfo.capabilities.includes('thinking')
|
||||
}
|
||||
|
||||
public async deleteModel(modelName: string) {
|
||||
public async deleteModel(modelName: string): Promise<{ success: boolean; message: string }> {
|
||||
await this._ensureDependencies()
|
||||
if (!this.ollama) {
|
||||
throw new Error('Ollama client is not initialized.')
|
||||
if (!this.baseUrl) {
|
||||
return { success: false, message: 'AI service is not initialized.' }
|
||||
}
|
||||
|
||||
return await this.ollama.delete({
|
||||
model: modelName,
|
||||
try {
|
||||
await axios.delete(`${this.baseUrl}/api/delete`, {
|
||||
data: { model: modelName },
|
||||
timeout: 10000,
|
||||
})
|
||||
return { success: true, message: `Model "${modelName}" deleted.` }
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[OllamaService] Failed to delete model "${modelName}": ${error instanceof Error ? error.message : error}`
|
||||
)
|
||||
return { success: false, message: 'Failed to delete model. This may not be an Ollama backend.' }
|
||||
}
|
||||
}
|
||||
|
||||
public async getModels(includeEmbeddings = false) {
|
||||
/**
|
||||
* Generate embeddings for the given input strings.
|
||||
* Tries the Ollama native /api/embed endpoint first, falls back to /v1/embeddings.
|
||||
*/
|
||||
public async embed(model: string, input: string[]): Promise<{ embeddings: number[][] }> {
|
||||
await this._ensureDependencies()
|
||||
if (!this.ollama) {
|
||||
throw new Error('Ollama client is not initialized.')
|
||||
if (!this.baseUrl || !this.openai) {
|
||||
throw new Error('AI service is not initialized.')
|
||||
}
|
||||
|
||||
try {
|
||||
// Prefer Ollama native endpoint (supports batch input natively)
|
||||
const response = await axios.post(
|
||||
`${this.baseUrl}/api/embed`,
|
||||
{ model, input },
|
||||
{ timeout: 60000 }
|
||||
)
|
||||
// Some backends (e.g. LM Studio) return HTTP 200 for unknown endpoints with an incompatible
|
||||
// body — validate explicitly before accepting the result.
|
||||
if (!Array.isArray(response.data?.embeddings)) {
|
||||
throw new Error('Invalid /api/embed response — missing embeddings array')
|
||||
}
|
||||
return { embeddings: response.data.embeddings }
|
||||
} catch {
|
||||
// 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.
|
||||
logger.info('[OllamaService] /api/embed unavailable, falling back to /v1/embeddings')
|
||||
const results = await this.openai.embeddings.create({ model, input, encoding_format: 'float' })
|
||||
return { embeddings: results.data.map((e) => e.embedding as number[]) }
|
||||
}
|
||||
}
|
||||
|
||||
public async getModels(includeEmbeddings = false): Promise<NomadInstalledModel[]> {
|
||||
await this._ensureDependencies()
|
||||
if (!this.baseUrl) {
|
||||
throw new Error('AI service is not initialized.')
|
||||
}
|
||||
|
||||
try {
|
||||
// Prefer the Ollama native endpoint which includes size and metadata
|
||||
const response = await axios.get(`${this.baseUrl}/api/tags`, { timeout: 5000 })
|
||||
// LM Studio returns HTTP 200 for unknown endpoints with an incompatible body — validate explicitly
|
||||
if (!Array.isArray(response.data?.models)) {
|
||||
throw new Error('Not an Ollama-compatible /api/tags response')
|
||||
}
|
||||
this.isOllamaNative = true
|
||||
const models: NomadInstalledModel[] = response.data.models
|
||||
if (includeEmbeddings) return models
|
||||
return models.filter((m) => !m.name.includes('embed'))
|
||||
} catch {
|
||||
// Fall back to the OpenAI-compatible /v1/models endpoint (LM Studio, llama.cpp, etc.)
|
||||
this.isOllamaNative = false
|
||||
logger.info('[OllamaService] /api/tags unavailable, falling back to /v1/models')
|
||||
try {
|
||||
const modelList = await this.openai!.models.list()
|
||||
const models: NomadInstalledModel[] = modelList.data.map((m) => ({ name: m.id, size: 0 }))
|
||||
if (includeEmbeddings) return models
|
||||
return models.filter((m) => !m.name.includes('embed'))
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`[OllamaService] Failed to list models: ${err instanceof Error ? err.message : err}`
|
||||
)
|
||||
return []
|
||||
}
|
||||
const response = await this.ollama.list()
|
||||
if (includeEmbeddings) {
|
||||
return response.models
|
||||
}
|
||||
// Filter out embedding models
|
||||
return response.models.filter((model) => !model.name.includes('embed'))
|
||||
}
|
||||
|
||||
async getAvailableModels(
|
||||
{ sort, recommendedOnly, query, limit, force }: { sort?: 'pulls' | 'name'; recommendedOnly?: boolean, query: string | null, limit?: number, force?: boolean } = {
|
||||
{
|
||||
sort,
|
||||
recommendedOnly,
|
||||
query,
|
||||
limit,
|
||||
force,
|
||||
}: {
|
||||
sort?: 'pulls' | 'name'
|
||||
recommendedOnly?: boolean
|
||||
query: string | null
|
||||
limit?: number
|
||||
force?: boolean
|
||||
} = {
|
||||
sort: 'pulls',
|
||||
recommendedOnly: false,
|
||||
query: null,
|
||||
limit: 15,
|
||||
}
|
||||
): Promise<{ models: NomadOllamaModel[], hasMore: boolean } | null> {
|
||||
): Promise<{ models: NomadOllamaModel[]; hasMore: boolean } | null> {
|
||||
try {
|
||||
const models = await this.retrieveAndRefreshModels(sort, force)
|
||||
if (!models) {
|
||||
// If we fail to get models from the API, return the fallback recommended models
|
||||
logger.warn(
|
||||
'[OllamaService] Returning fallback recommended models due to failure in fetching available models'
|
||||
)
|
||||
return {
|
||||
models: FALLBACK_RECOMMENDED_OLLAMA_MODELS,
|
||||
hasMore: false
|
||||
hasMore: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -207,15 +455,13 @@ export class OllamaService {
|
|||
const filteredModels = query ? this.fuseSearchModels(models, query) : models
|
||||
return {
|
||||
models: filteredModels.slice(0, limit || 15),
|
||||
hasMore: filteredModels.length > (limit || 15)
|
||||
hasMore: filteredModels.length > (limit || 15),
|
||||
}
|
||||
}
|
||||
|
||||
// If recommendedOnly is true, only return the first three models (if sorted by pulls, these will be the top 3)
|
||||
const sortedByPulls = sort === 'pulls' ? models : this.sortModels(models, 'pulls')
|
||||
const firstThree = sortedByPulls.slice(0, 3)
|
||||
|
||||
// Only return the first tag of each of these models (should be the most lightweight variant)
|
||||
const recommendedModels = firstThree.map((model) => {
|
||||
return {
|
||||
...model,
|
||||
|
|
@ -227,13 +473,13 @@ export class OllamaService {
|
|||
const filteredRecommendedModels = this.fuseSearchModels(recommendedModels, query)
|
||||
return {
|
||||
models: filteredRecommendedModels,
|
||||
hasMore: filteredRecommendedModels.length > (limit || 15)
|
||||
hasMore: filteredRecommendedModels.length > (limit || 15),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
models: recommendedModels,
|
||||
hasMore: recommendedModels.length > (limit || 15)
|
||||
hasMore: recommendedModels.length > (limit || 15),
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
|
|
@ -273,7 +519,6 @@ export class OllamaService {
|
|||
|
||||
const rawModels = response.data.models as NomadOllamaModel[]
|
||||
|
||||
// Filter out tags where cloud is truthy, then remove models with no remaining tags
|
||||
const noCloud = rawModels
|
||||
.map((model) => ({
|
||||
...model,
|
||||
|
|
@ -285,8 +530,7 @@ export class OllamaService {
|
|||
return this.sortModels(noCloud, sort)
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[OllamaService] Failed to retrieve models from Nomad API: ${error instanceof Error ? error.message : error
|
||||
}`
|
||||
`[OllamaService] Failed to retrieve models from Nomad API: ${error instanceof Error ? error.message : error}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
|
@ -312,7 +556,6 @@ export class OllamaService {
|
|||
|
||||
return models
|
||||
} catch (error) {
|
||||
// Cache doesn't exist or is invalid
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.warn(
|
||||
`[OllamaService] Error reading cache: ${error instanceof Error ? error.message : error}`
|
||||
|
|
@ -336,7 +579,6 @@ export class OllamaService {
|
|||
|
||||
private sortModels(models: NomadOllamaModel[], sort?: 'pulls' | 'name'): NomadOllamaModel[] {
|
||||
if (sort === 'pulls') {
|
||||
// Sort by estimated pulls (it should be a string like "1.2K", "500", "4M" etc.)
|
||||
models.sort((a, b) => {
|
||||
const parsePulls = (pulls: string) => {
|
||||
const multiplier = pulls.endsWith('K')
|
||||
|
|
@ -354,8 +596,6 @@ export class OllamaService {
|
|||
models.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
// Always sort model.tags by the size field in descending order
|
||||
// Size is a string like '75GB', '8.5GB', '2GB' etc. Smaller models first
|
||||
models.forEach((model) => {
|
||||
if (model.tags && Array.isArray(model.tags)) {
|
||||
model.tags.sort((a, b) => {
|
||||
|
|
@ -368,7 +608,7 @@ export class OllamaService {
|
|||
? 1
|
||||
: size.endsWith('TB')
|
||||
? 1_000
|
||||
: 0 // Unknown size format
|
||||
: 0
|
||||
return parseFloat(size) * multiplier
|
||||
}
|
||||
return parseSize(a.size) - parseSize(b.size)
|
||||
|
|
@ -379,6 +619,15 @@ export class OllamaService {
|
|||
return models
|
||||
}
|
||||
|
||||
private broadcastDownloadError(model: string, error: string) {
|
||||
transmit.broadcast(BROADCAST_CHANNELS.OLLAMA_MODEL_DOWNLOAD, {
|
||||
model,
|
||||
percent: -1,
|
||||
error,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
private broadcastDownloadProgress(model: string, percent: number) {
|
||||
transmit.broadcast(BROADCAST_CHANNELS.OLLAMA_MODEL_DOWNLOAD, {
|
||||
model,
|
||||
|
|
@ -392,11 +641,11 @@ export class OllamaService {
|
|||
const options: IFuseOptions<NomadOllamaModel> = {
|
||||
ignoreDiacritics: true,
|
||||
keys: ['name', 'description', 'tags.name'],
|
||||
threshold: 0.3, // lower threshold for stricter matching
|
||||
threshold: 0.3,
|
||||
}
|
||||
|
||||
const fuse = new Fuse(models, options)
|
||||
|
||||
return fuse.search(query).map(result => result.item)
|
||||
return fuse.search(query).map((result) => result.item)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { deleteFileIfExists, determineFileType, getFile, getFileStatsIfExists, l
|
|||
import { PDFParse } from 'pdf-parse'
|
||||
import { createWorker } from 'tesseract.js'
|
||||
import { fromBuffer } from 'pdf2pic'
|
||||
import JSZip from 'jszip'
|
||||
import * as cheerio from 'cheerio'
|
||||
import { OllamaService } from './ollama_service.js'
|
||||
import { SERVICE_NAMES } from '../../constants/service_names.js'
|
||||
import { removeStopwords } from 'stopword'
|
||||
|
|
@ -23,15 +25,18 @@ export class RagService {
|
|||
private qdrant: QdrantClient | null = null
|
||||
private qdrantInitPromise: Promise<void> | null = null
|
||||
private embeddingModelVerified = false
|
||||
private resolvedEmbeddingModel: string | null = null
|
||||
public static UPLOADS_STORAGE_PATH = 'storage/kb_uploads'
|
||||
public static CONTENT_COLLECTION_NAME = 'nomad_knowledge_base'
|
||||
public static EMBEDDING_MODEL = 'nomic-embed-text:v1.5'
|
||||
public static EMBEDDING_DIMENSION = 768 // Nomic Embed Text v1.5 dimension is 768
|
||||
public static MODEL_CONTEXT_LENGTH = 2048 // nomic-embed-text has 2K token context
|
||||
public static MAX_SAFE_TOKENS = 1800 // Leave buffer for prefix and tokenization variance
|
||||
public static TARGET_TOKENS_PER_CHUNK = 1700 // Target 1700 tokens per chunk for embedding
|
||||
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
|
||||
public static PREFIX_TOKEN_BUDGET = 10 // Reserve ~10 tokens for prefixes
|
||||
public static CHAR_TO_TOKEN_RATIO = 3 // Approximate chars per token
|
||||
public static CHAR_TO_TOKEN_RATIO = 2 // Conservative chars-per-token estimate; technical docs
|
||||
// (numbers, symbols, abbreviations) tokenize denser
|
||||
// than plain prose (~3), so 2 avoids context overflows
|
||||
// Nomic Embed Text v1.5 uses task-specific prefixes for optimal performance
|
||||
public static SEARCH_DOCUMENT_PREFIX = 'search_document: '
|
||||
public static SEARCH_QUERY_PREFIX = 'search_query: '
|
||||
|
|
@ -245,7 +250,9 @@ export class RagService {
|
|||
|
||||
if (!this.embeddingModelVerified) {
|
||||
const allModels = await this.ollamaService.getModels(true)
|
||||
const embeddingModel = allModels.find((model) => model.name === RagService.EMBEDDING_MODEL)
|
||||
const embeddingModel =
|
||||
allModels.find((model) => model.name === RagService.EMBEDDING_MODEL) ??
|
||||
allModels.find((model) => model.name.toLowerCase().includes('nomic-embed-text'))
|
||||
|
||||
if (!embeddingModel) {
|
||||
try {
|
||||
|
|
@ -262,6 +269,7 @@ export class RagService {
|
|||
return null
|
||||
}
|
||||
}
|
||||
this.resolvedEmbeddingModel = embeddingModel?.name ?? RagService.EMBEDDING_MODEL
|
||||
this.embeddingModelVerified = true
|
||||
}
|
||||
|
||||
|
|
@ -285,8 +293,6 @@ export class RagService {
|
|||
// Extract text from chunk results
|
||||
const chunks = chunkResults.map((chunk) => chunk.text)
|
||||
|
||||
const ollamaClient = await this.ollamaService.getClient()
|
||||
|
||||
// Prepare all chunk texts with prefix and truncation
|
||||
const prefixedChunks: string[] = []
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
|
|
@ -320,10 +326,7 @@ export class RagService {
|
|||
|
||||
logger.debug(`[RAG] Embedding batch ${batchIdx + 1}/${totalBatches} (${batch.length} chunks)`)
|
||||
|
||||
const response = await ollamaClient.embed({
|
||||
model: RagService.EMBEDDING_MODEL,
|
||||
input: batch,
|
||||
})
|
||||
const response = await this.ollamaService.embed(this.resolvedEmbeddingModel ?? RagService.EMBEDDING_MODEL, batch)
|
||||
|
||||
embeddings.push(...response.embeddings)
|
||||
|
||||
|
|
@ -564,6 +567,86 @@ export class RagService {
|
|||
return await this.extractTXTText(fileBuffer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content from an EPUB file.
|
||||
* EPUBs are ZIP archives containing XHTML content files.
|
||||
* Reads the OPF manifest to determine reading order, then extracts
|
||||
* text from each content document in sequence.
|
||||
*/
|
||||
private async processEPUBFile(fileBuffer: Buffer): Promise<string> {
|
||||
const zip = await JSZip.loadAsync(fileBuffer)
|
||||
|
||||
// Read container.xml to find the OPF file path
|
||||
const containerXml = await zip.file('META-INF/container.xml')?.async('text')
|
||||
if (!containerXml) {
|
||||
throw new Error('Invalid EPUB: missing META-INF/container.xml')
|
||||
}
|
||||
|
||||
// Parse container.xml to get the OPF rootfile path
|
||||
const $container = cheerio.load(containerXml, { xml: true })
|
||||
const opfPath = $container('rootfile').attr('full-path')
|
||||
if (!opfPath) {
|
||||
throw new Error('Invalid EPUB: no rootfile found in container.xml')
|
||||
}
|
||||
|
||||
// Determine the base directory of the OPF file for resolving relative paths
|
||||
const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/') + 1) : ''
|
||||
|
||||
// Read and parse the OPF file
|
||||
const opfContent = await zip.file(opfPath)?.async('text')
|
||||
if (!opfContent) {
|
||||
throw new Error(`Invalid EPUB: OPF file not found at ${opfPath}`)
|
||||
}
|
||||
|
||||
const $opf = cheerio.load(opfContent, { xml: true })
|
||||
|
||||
// Build a map of manifest items (id -> href)
|
||||
const manifestItems = new Map<string, string>()
|
||||
$opf('manifest item').each((_, el) => {
|
||||
const id = $opf(el).attr('id')
|
||||
const href = $opf(el).attr('href')
|
||||
const mediaType = $opf(el).attr('media-type') || ''
|
||||
// Only include XHTML/HTML content documents
|
||||
if (id && href && (mediaType.includes('html') || mediaType.includes('xml'))) {
|
||||
manifestItems.set(id, href)
|
||||
}
|
||||
})
|
||||
|
||||
// Get the reading order from the spine
|
||||
const spineOrder: string[] = []
|
||||
$opf('spine itemref').each((_, el) => {
|
||||
const idref = $opf(el).attr('idref')
|
||||
if (idref && manifestItems.has(idref)) {
|
||||
spineOrder.push(manifestItems.get(idref)!)
|
||||
}
|
||||
})
|
||||
|
||||
// If no spine found, fall back to all manifest items
|
||||
const contentFiles = spineOrder.length > 0
|
||||
? spineOrder
|
||||
: Array.from(manifestItems.values())
|
||||
|
||||
// Extract text from each content file in order
|
||||
const textParts: string[] = []
|
||||
for (const href of contentFiles) {
|
||||
const fullPath = opfDir + href
|
||||
const content = await zip.file(fullPath)?.async('text')
|
||||
if (content) {
|
||||
const $ = cheerio.load(content)
|
||||
// Remove script and style elements
|
||||
$('script, style').remove()
|
||||
const text = $('body').text().trim()
|
||||
if (text) {
|
||||
textParts.push(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fullText = textParts.join('\n\n')
|
||||
logger.debug(`[RAG] EPUB extracted ${textParts.length} chapters, ${fullText.length} characters total`)
|
||||
return fullText
|
||||
}
|
||||
|
||||
private async embedTextAndCleanup(
|
||||
extractedText: string,
|
||||
filepath: string,
|
||||
|
|
@ -638,6 +721,9 @@ export class RagService {
|
|||
case 'pdf':
|
||||
extractedText = await this.processPDFFile(fileBuffer!)
|
||||
break
|
||||
case 'epub':
|
||||
extractedText = await this.processEPUBFile(fileBuffer!)
|
||||
break
|
||||
case 'text':
|
||||
default:
|
||||
extractedText = await this.processTextFile(fileBuffer!)
|
||||
|
|
@ -692,7 +778,9 @@ export class RagService {
|
|||
|
||||
if (!this.embeddingModelVerified) {
|
||||
const allModels = await this.ollamaService.getModels(true)
|
||||
const embeddingModel = allModels.find((model) => model.name === RagService.EMBEDDING_MODEL)
|
||||
const embeddingModel =
|
||||
allModels.find((model) => model.name === RagService.EMBEDDING_MODEL) ??
|
||||
allModels.find((model) => model.name.toLowerCase().includes('nomic-embed-text'))
|
||||
|
||||
if (!embeddingModel) {
|
||||
logger.warn(
|
||||
|
|
@ -701,6 +789,7 @@ export class RagService {
|
|||
this.embeddingModelVerified = false
|
||||
return []
|
||||
}
|
||||
this.resolvedEmbeddingModel = embeddingModel.name
|
||||
this.embeddingModelVerified = true
|
||||
}
|
||||
|
||||
|
|
@ -710,8 +799,6 @@ export class RagService {
|
|||
logger.debug(`[RAG] Extracted keywords: [${keywords.join(', ')}]`)
|
||||
|
||||
// Generate embedding for the query with search_query prefix
|
||||
const ollamaClient = await this.ollamaService.getClient()
|
||||
|
||||
// Ensure query doesn't exceed token limit
|
||||
const prefixTokens = this.estimateTokenCount(RagService.SEARCH_QUERY_PREFIX)
|
||||
const maxQueryTokens = RagService.MAX_SAFE_TOKENS - prefixTokens
|
||||
|
|
@ -729,10 +816,7 @@ export class RagService {
|
|||
return []
|
||||
}
|
||||
|
||||
const response = await ollamaClient.embed({
|
||||
model: RagService.EMBEDDING_MODEL,
|
||||
input: [prefixedQuery],
|
||||
})
|
||||
const response = await this.ollamaService.embed(this.resolvedEmbeddingModel ?? RagService.EMBEDDING_MODEL, [prefixedQuery])
|
||||
|
||||
// Perform semantic search with a higher limit to enable reranking
|
||||
const searchLimit = limit * 3 // Get more results for reranking
|
||||
|
|
|
|||
|
|
@ -4,17 +4,22 @@ import { DockerService } from '#services/docker_service'
|
|||
import { ServiceSlim } from '../../types/services.js'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import si from 'systeminformation'
|
||||
import { GpuHealthStatus, NomadDiskInfo, NomadDiskInfoRaw, SystemInformationResponse } from '../../types/system.js'
|
||||
import {
|
||||
GpuHealthStatus,
|
||||
NomadDiskInfo,
|
||||
NomadDiskInfoRaw,
|
||||
SystemInformationResponse,
|
||||
} from '../../types/system.js'
|
||||
import { SERVICE_NAMES } from '../../constants/service_names.js'
|
||||
import { readFileSync } from 'fs'
|
||||
import path, { join } from 'path'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path, { join } from 'node:path'
|
||||
import { getAllFilesystems, getFile } from '../utils/fs.js'
|
||||
import axios from 'axios'
|
||||
import env from '#start/env'
|
||||
import KVStore from '#models/kv_store'
|
||||
import { KV_STORE_SCHEMA, KVStoreKey } from '../../types/kv_store.js'
|
||||
import { isNewerVersion } from '../utils/version.js'
|
||||
|
||||
import { invalidateAssistantNameCache } from '../../config/inertia.js'
|
||||
|
||||
@inject()
|
||||
export class SystemService {
|
||||
|
|
@ -24,8 +29,8 @@ export class SystemService {
|
|||
constructor(private dockerService: DockerService) {}
|
||||
|
||||
async checkServiceInstalled(serviceName: string): Promise<boolean> {
|
||||
const services = await this.getServices({ installedOnly: true });
|
||||
return services.some(service => service.service_name === serviceName);
|
||||
const services = await this.getServices({ installedOnly: true })
|
||||
return services.some((service) => service.service_name === serviceName)
|
||||
}
|
||||
|
||||
async getInternetStatus(): Promise<boolean> {
|
||||
|
|
@ -67,14 +72,20 @@ export class SystemService {
|
|||
return false
|
||||
}
|
||||
|
||||
async getNvidiaSmiInfo(): Promise<Array<{ vendor: string; model: string; vram: number; }> | { error: string } | 'OLLAMA_NOT_FOUND' | 'BAD_RESPONSE' | 'UNKNOWN_ERROR'> {
|
||||
async getNvidiaSmiInfo(): Promise<
|
||||
| Array<{ vendor: string; model: string; vram: number }>
|
||||
| { error: string }
|
||||
| 'OLLAMA_NOT_FOUND'
|
||||
| 'BAD_RESPONSE'
|
||||
| 'UNKNOWN_ERROR'
|
||||
> {
|
||||
try {
|
||||
const containers = await this.dockerService.docker.listContainers({ all: false })
|
||||
const ollamaContainer = containers.find((c) =>
|
||||
c.Names.includes(`/${SERVICE_NAMES.OLLAMA}`)
|
||||
)
|
||||
const ollamaContainer = containers.find((c) => c.Names.includes(`/${SERVICE_NAMES.OLLAMA}`))
|
||||
if (!ollamaContainer) {
|
||||
logger.info('Ollama container not found for nvidia-smi info retrieval. This is expected if Ollama is not installed.')
|
||||
logger.info(
|
||||
'Ollama container not found for nvidia-smi info retrieval. This is expected if Ollama is not installed.'
|
||||
)
|
||||
return 'OLLAMA_NOT_FOUND'
|
||||
}
|
||||
|
||||
|
|
@ -92,23 +103,35 @@ export class SystemService {
|
|||
const output = await new Promise<string>((resolve) => {
|
||||
let data = ''
|
||||
const timeout = setTimeout(() => resolve(data), 5000)
|
||||
stream.on('data', (chunk: Buffer) => { data += chunk.toString() })
|
||||
stream.on('end', () => { clearTimeout(timeout); resolve(data) })
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
data += chunk.toString()
|
||||
})
|
||||
stream.on('end', () => {
|
||||
clearTimeout(timeout)
|
||||
resolve(data)
|
||||
})
|
||||
})
|
||||
|
||||
// Remove any non-printable characters and trim the output
|
||||
const cleaned = output.replace(/[\x00-\x08]/g, '').trim()
|
||||
if (cleaned && !cleaned.toLowerCase().includes('error') && !cleaned.toLowerCase().includes('not found')) {
|
||||
const cleaned = Array.from(output)
|
||||
.filter((character) => character.charCodeAt(0) > 8)
|
||||
.join('')
|
||||
.trim()
|
||||
if (
|
||||
cleaned &&
|
||||
!cleaned.toLowerCase().includes('error') &&
|
||||
!cleaned.toLowerCase().includes('not found')
|
||||
) {
|
||||
// Split by newlines to handle multiple GPUs installed
|
||||
const lines = cleaned.split('\n').filter(line => line.trim())
|
||||
const lines = cleaned.split('\n').filter((line) => line.trim())
|
||||
|
||||
// Map each line out to a useful structure for us
|
||||
const gpus = lines.map(line => {
|
||||
const gpus = lines.map((line) => {
|
||||
const parts = line.split(',').map((s) => s.trim())
|
||||
return {
|
||||
vendor: 'NVIDIA',
|
||||
model: parts[0] || 'NVIDIA GPU',
|
||||
vram: parts[1] ? parseInt(parts[1], 10) : 0,
|
||||
vram: parts[1] ? Number.parseInt(parts[1], 10) : 0,
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -117,8 +140,7 @@ export class SystemService {
|
|||
|
||||
// If we got output but looks like an error, consider it a bad response from nvidia-smi
|
||||
return 'BAD_RESPONSE'
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
logger.error('Error getting nvidia-smi info:', error)
|
||||
if (error instanceof Error && error.message) {
|
||||
return { error: error.message }
|
||||
|
|
@ -127,8 +149,65 @@ export class SystemService {
|
|||
}
|
||||
}
|
||||
|
||||
async getExternalOllamaGpuInfo(): Promise<Array<{
|
||||
vendor: string
|
||||
model: string
|
||||
vram: number
|
||||
}> | null> {
|
||||
try {
|
||||
// If a remote Ollama URL is configured, use it directly without requiring a local container
|
||||
const remoteOllamaUrl = await KVStore.getValue('ai.remoteOllamaUrl')
|
||||
if (!remoteOllamaUrl) {
|
||||
const containers = await this.dockerService.docker.listContainers({ all: false })
|
||||
const ollamaContainer = containers.find((c) => c.Names.includes(`/${SERVICE_NAMES.OLLAMA}`))
|
||||
if (!ollamaContainer) {
|
||||
return null
|
||||
}
|
||||
|
||||
const actualImage = (ollamaContainer.Image || '').toLowerCase()
|
||||
if (actualImage.includes('ollama/ollama') || actualImage.startsWith('ollama:')) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const ollamaUrl = remoteOllamaUrl || (await this.dockerService.getServiceURL(SERVICE_NAMES.OLLAMA))
|
||||
if (!ollamaUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
await axios.get(new URL('/api/tags', ollamaUrl).toString(), { timeout: 3000 })
|
||||
|
||||
let vramMb = 0
|
||||
try {
|
||||
const psResponse = await axios.get(new URL('/api/ps', ollamaUrl).toString(), {
|
||||
timeout: 3000,
|
||||
})
|
||||
const loadedModels = Array.isArray(psResponse.data?.models) ? psResponse.data.models : []
|
||||
const largestAllocation = loadedModels.reduce(
|
||||
(max: number, model: { size_vram?: number | string }) =>
|
||||
Math.max(max, Number(model.size_vram) || 0),
|
||||
0
|
||||
)
|
||||
vramMb = largestAllocation > 0 ? Math.round(largestAllocation / (1024 * 1024)) : 0
|
||||
} catch {}
|
||||
|
||||
return [
|
||||
{
|
||||
vendor: 'NVIDIA',
|
||||
model: 'NVIDIA GPU (external Ollama)',
|
||||
vram: vramMb,
|
||||
},
|
||||
]
|
||||
} catch (error) {
|
||||
logger.info(
|
||||
`[SystemService] External Ollama GPU probe failed: ${error instanceof Error ? error.message : error}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async getServices({ installedOnly = true }: { installedOnly?: boolean }): Promise<ServiceSlim[]> {
|
||||
await this._syncContainersWithDatabase() // Sync up before fetching to ensure we have the latest status
|
||||
const statuses = await this._syncContainersWithDatabase() // Sync and reuse the fetched status list
|
||||
|
||||
const query = Service.query()
|
||||
.orderBy('display_order', 'asc')
|
||||
|
|
@ -157,8 +236,6 @@ export class SystemService {
|
|||
return []
|
||||
}
|
||||
|
||||
const statuses = await this.dockerService.getServicesStatus()
|
||||
|
||||
const toReturn: ServiceSlim[] = []
|
||||
|
||||
for (const service of services) {
|
||||
|
|
@ -273,17 +350,46 @@ export class SystemService {
|
|||
graphics.controllers = nvidiaInfo.map((gpu) => ({
|
||||
model: gpu.model,
|
||||
vendor: gpu.vendor,
|
||||
bus: "",
|
||||
bus: '',
|
||||
vram: gpu.vram,
|
||||
vramDynamic: false, // assume false here, we don't actually use this field for our purposes.
|
||||
}))
|
||||
gpuHealth.status = 'ok'
|
||||
gpuHealth.ollamaGpuAccessible = true
|
||||
} else if (nvidiaInfo === 'OLLAMA_NOT_FOUND') {
|
||||
// No local Ollama container — check if a remote Ollama URL is configured
|
||||
const externalOllamaGpu = await this.getExternalOllamaGpuInfo()
|
||||
if (externalOllamaGpu) {
|
||||
graphics.controllers = externalOllamaGpu.map((gpu) => ({
|
||||
model: gpu.model,
|
||||
vendor: gpu.vendor,
|
||||
bus: '',
|
||||
vram: gpu.vram,
|
||||
vramDynamic: false,
|
||||
}))
|
||||
gpuHealth.status = 'ok'
|
||||
gpuHealth.ollamaGpuAccessible = true
|
||||
} else {
|
||||
gpuHealth.status = 'ollama_not_installed'
|
||||
}
|
||||
} else {
|
||||
const externalOllamaGpu = await this.getExternalOllamaGpuInfo()
|
||||
if (externalOllamaGpu) {
|
||||
graphics.controllers = externalOllamaGpu.map((gpu) => ({
|
||||
model: gpu.model,
|
||||
vendor: gpu.vendor,
|
||||
bus: '',
|
||||
vram: gpu.vram,
|
||||
vramDynamic: false,
|
||||
}))
|
||||
gpuHealth.status = 'ok'
|
||||
gpuHealth.ollamaGpuAccessible = true
|
||||
} else {
|
||||
gpuHealth.status = 'passthrough_failed'
|
||||
logger.warn(`NVIDIA runtime detected but GPU passthrough failed: ${typeof nvidiaInfo === 'string' ? nvidiaInfo : JSON.stringify(nvidiaInfo)}`)
|
||||
logger.warn(
|
||||
`NVIDIA runtime detected but GPU passthrough failed: ${typeof nvidiaInfo === 'string' ? nvidiaInfo : JSON.stringify(nvidiaInfo)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -356,7 +462,8 @@ export class SystemService {
|
|||
|
||||
logger.info(`Current version: ${currentVersion}, Latest version: ${latestVersion}`)
|
||||
|
||||
const updateAvailable = process.env.NODE_ENV === 'development'
|
||||
const updateAvailable =
|
||||
process.env.NODE_ENV === 'development'
|
||||
? false
|
||||
: isNewerVersion(latestVersion, currentVersion.trim(), earlyAccess)
|
||||
|
||||
|
|
@ -410,12 +517,129 @@ export class SystemService {
|
|||
}
|
||||
}
|
||||
|
||||
async getDebugInfo(): Promise<string> {
|
||||
const appVersion = SystemService.getAppVersion()
|
||||
const environment = process.env.NODE_ENV || 'unknown'
|
||||
|
||||
const [systemInfo, services, internetStatus, versionCheck] = await Promise.all([
|
||||
this.getSystemInfo(),
|
||||
this.getServices({ installedOnly: false }),
|
||||
this.getInternetStatus().catch(() => null),
|
||||
this.checkLatestVersion().catch(() => null),
|
||||
])
|
||||
|
||||
const lines: string[] = [
|
||||
'Project NOMAD Debug Info',
|
||||
'========================',
|
||||
`App Version: ${appVersion}`,
|
||||
`Environment: ${environment}`,
|
||||
]
|
||||
|
||||
if (systemInfo) {
|
||||
const { cpu, mem, os, disk, fsSize, uptime, graphics } = systemInfo
|
||||
|
||||
lines.push('')
|
||||
lines.push('System:')
|
||||
if (os.distro) lines.push(` OS: ${os.distro}`)
|
||||
if (os.hostname) lines.push(` Hostname: ${os.hostname}`)
|
||||
if (os.kernel) lines.push(` Kernel: ${os.kernel}`)
|
||||
if (os.arch) lines.push(` Architecture: ${os.arch}`)
|
||||
if (uptime?.uptime) lines.push(` Uptime: ${this._formatUptime(uptime.uptime)}`)
|
||||
|
||||
lines.push('')
|
||||
lines.push('Hardware:')
|
||||
if (cpu.brand) {
|
||||
lines.push(` CPU: ${cpu.brand} (${cpu.cores} cores)`)
|
||||
}
|
||||
if (mem.total) {
|
||||
const total = this._formatBytes(mem.total)
|
||||
const used = this._formatBytes(mem.total - (mem.available || 0))
|
||||
const available = this._formatBytes(mem.available || 0)
|
||||
lines.push(` RAM: ${total} total, ${used} used, ${available} available`)
|
||||
}
|
||||
if (graphics.controllers && graphics.controllers.length > 0) {
|
||||
for (const gpu of graphics.controllers) {
|
||||
const vram = gpu.vram ? ` (${gpu.vram} MB VRAM)` : ''
|
||||
lines.push(` GPU: ${gpu.model}${vram}`)
|
||||
}
|
||||
} else {
|
||||
lines.push(' GPU: None detected')
|
||||
}
|
||||
|
||||
// Disk info — try disk array first, fall back to fsSize
|
||||
const diskEntries = disk.filter((d) => d.totalSize > 0)
|
||||
if (diskEntries.length > 0) {
|
||||
for (const d of diskEntries) {
|
||||
const size = this._formatBytes(d.totalSize)
|
||||
const type = d.tran?.toUpperCase() || (d.rota ? 'HDD' : 'SSD')
|
||||
lines.push(` Disk: ${size}, ${Math.round(d.percentUsed)}% used, ${type}`)
|
||||
}
|
||||
} else if (fsSize.length > 0) {
|
||||
const realFs = fsSize.filter((f) => f.fs.startsWith('/dev/'))
|
||||
const seen = new Set<number>()
|
||||
for (const f of realFs) {
|
||||
if (seen.has(f.size)) continue
|
||||
seen.add(f.size)
|
||||
lines.push(` Disk: ${this._formatBytes(f.size)}, ${Math.round(f.use)}% used`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const installed = services.filter((s) => s.installed)
|
||||
lines.push('')
|
||||
if (installed.length > 0) {
|
||||
lines.push('Installed Services:')
|
||||
for (const svc of installed) {
|
||||
lines.push(` ${svc.friendly_name} (${svc.service_name}): ${svc.status}`)
|
||||
}
|
||||
} else {
|
||||
lines.push('Installed Services: None')
|
||||
}
|
||||
|
||||
if (internetStatus !== null) {
|
||||
lines.push('')
|
||||
lines.push(`Internet Status: ${internetStatus ? 'Online' : 'Offline'}`)
|
||||
}
|
||||
|
||||
if (versionCheck?.success) {
|
||||
const updateMsg = versionCheck.updateAvailable
|
||||
? `Yes (${versionCheck.latestVersion} available)`
|
||||
: `No (${versionCheck.currentVersion} is latest)`
|
||||
lines.push(`Update Available: ${updateMsg}`)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
private _formatUptime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
if (days > 0) return `${days}d ${hours}h ${minutes}m`
|
||||
if (hours > 0) return `${hours}h ${minutes}m`
|
||||
return `${minutes}m`
|
||||
}
|
||||
|
||||
private _formatBytes(bytes: number, decimals = 1): string {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
async updateSetting(key: KVStoreKey, value: any): Promise<void> {
|
||||
if ((value === '' || value === undefined || value === null) && KV_STORE_SCHEMA[key] === 'string') {
|
||||
if (
|
||||
(value === '' || value === undefined || value === null) &&
|
||||
KV_STORE_SCHEMA[key] === 'string'
|
||||
) {
|
||||
await KVStore.clearValue(key)
|
||||
} else {
|
||||
await KVStore.setValue(key, value)
|
||||
}
|
||||
if (key === 'ai.assistantCustomName') {
|
||||
invalidateAssistantNameCache()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -423,8 +647,9 @@ export class SystemService {
|
|||
* It will mark services as not installed if their corresponding containers do not exist, regardless of their running state.
|
||||
* Handles cases where a container might have been manually removed, ensuring the database reflects the actual existence of containers.
|
||||
* Containers that exist but are stopped, paused, or restarting will still be considered installed.
|
||||
* Returns the fetched service status list so callers can reuse it without a second Docker API call.
|
||||
*/
|
||||
private async _syncContainersWithDatabase() {
|
||||
private async _syncContainersWithDatabase(): Promise<{ service_name: string; status: string }[]> {
|
||||
try {
|
||||
const allServices = await Service.all()
|
||||
const serviceStatusList = await this.dockerService.getServicesStatus()
|
||||
|
|
@ -437,6 +662,11 @@ export class SystemService {
|
|||
if (service.installed) {
|
||||
// If marked as installed but container doesn't exist, mark as not installed
|
||||
if (!containerExists) {
|
||||
// Exception: remote Ollama is configured without a local container — don't reset it
|
||||
if (service.service_name === SERVICE_NAMES.OLLAMA) {
|
||||
const remoteUrl = await KVStore.getValue('ai.remoteOllamaUrl')
|
||||
if (remoteUrl) continue
|
||||
}
|
||||
logger.warn(
|
||||
`Service ${service.service_name} is marked as installed but container does not exist. Marking as not installed.`
|
||||
)
|
||||
|
|
@ -456,8 +686,11 @@ export class SystemService {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
return serviceStatusList
|
||||
} catch (error) {
|
||||
logger.error('Error syncing containers with database:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -468,10 +701,21 @@ export class SystemService {
|
|||
return []
|
||||
}
|
||||
|
||||
// Deduplicate: same device path mounted in multiple places (Docker bind-mounts)
|
||||
// Keep the entry with the largest size — that's the real partition
|
||||
const deduped = new Map<string, NomadDiskInfoRaw['fsSize'][0]>()
|
||||
for (const entry of fsSize) {
|
||||
const existing = deduped.get(entry.fs)
|
||||
if (!existing || entry.size > existing.size) {
|
||||
deduped.set(entry.fs, entry)
|
||||
}
|
||||
}
|
||||
const dedupedFsSize = Array.from(deduped.values())
|
||||
|
||||
return diskLayout.blockdevices
|
||||
.filter((disk) => disk.type === 'disk') // Only physical disks
|
||||
.map((disk) => {
|
||||
const filesystems = getAllFilesystems(disk, fsSize)
|
||||
const filesystems = getAllFilesystems(disk, dedupedFsSize)
|
||||
|
||||
// Across all partitions
|
||||
const totalUsed = filesystems.reduce((sum, p) => sum + (p.used || 0), 0)
|
||||
|
|
@ -498,5 +742,4 @@ export class SystemService {
|
|||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import InstalledResource from '#models/installed_resource'
|
|||
import { RunDownloadJob } from '#jobs/run_download_job'
|
||||
import { SERVICE_NAMES } from '../../constants/service_names.js'
|
||||
import { CollectionManifestService } from './collection_manifest_service.js'
|
||||
import { KiwixLibraryService } from './kiwix_library_service.js'
|
||||
import type { CategoryWithStatus } from '../../types/collections.js'
|
||||
|
||||
const ZIM_MIME_TYPES = ['application/x-zim', 'application/x-openzim', 'application/octet-stream']
|
||||
|
|
@ -137,13 +138,13 @@ export class ZimService {
|
|||
}
|
||||
}
|
||||
|
||||
async downloadRemote(url: string): Promise<{ filename: string; jobId?: string }> {
|
||||
async downloadRemote(url: string, metadata?: { title?: string; summary?: string; author?: string; size_bytes?: number }): Promise<{ filename: string; jobId?: string }> {
|
||||
const parsed = new URL(url)
|
||||
if (!parsed.pathname.endsWith('.zim')) {
|
||||
throw new Error(`Invalid ZIM file URL: ${url}. URL must end with .zim`)
|
||||
}
|
||||
|
||||
const existing = await RunDownloadJob.getByUrl(url)
|
||||
const existing = await RunDownloadJob.getActiveByUrl(url)
|
||||
if (existing) {
|
||||
throw new Error('A download for this URL is already in progress')
|
||||
}
|
||||
|
|
@ -170,6 +171,8 @@ export class ZimService {
|
|||
allowedMimeTypes: ZIM_MIME_TYPES,
|
||||
forceNew: true,
|
||||
filetype: 'zim',
|
||||
title: metadata?.title,
|
||||
totalBytes: metadata?.size_bytes,
|
||||
resourceMetadata,
|
||||
})
|
||||
|
||||
|
|
@ -219,7 +222,7 @@ export class ZimService {
|
|||
const downloadFilenames: string[] = []
|
||||
|
||||
for (const resource of toDownload) {
|
||||
const existingJob = await RunDownloadJob.getByUrl(resource.url)
|
||||
const existingJob = await RunDownloadJob.getActiveByUrl(resource.url)
|
||||
if (existingJob) {
|
||||
logger.warn(`[ZimService] Download already in progress for ${resource.url}, skipping.`)
|
||||
continue
|
||||
|
|
@ -238,6 +241,8 @@ export class ZimService {
|
|||
allowedMimeTypes: ZIM_MIME_TYPES,
|
||||
forceNew: true,
|
||||
filetype: 'zim',
|
||||
title: (resource as any).title || undefined,
|
||||
totalBytes: (resource as any).size_mb ? (resource as any).size_mb * 1024 * 1024 : undefined,
|
||||
resourceMetadata: {
|
||||
resource_id: resource.id,
|
||||
version: resource.version,
|
||||
|
|
@ -257,6 +262,17 @@ export class ZimService {
|
|||
}
|
||||
}
|
||||
|
||||
// Update the kiwix library XML after all downloaded ZIM files are in place.
|
||||
// This covers all ZIM types including Wikipedia. Rebuilding once from disk
|
||||
// avoids repeated XML parse/write cycles and reduces the chance of write races
|
||||
// when multiple download jobs complete concurrently.
|
||||
const kiwixLibraryService = new KiwixLibraryService()
|
||||
try {
|
||||
await kiwixLibraryService.rebuildFromDisk()
|
||||
} catch (err) {
|
||||
logger.error('[ZimService] Failed to rebuild kiwix library from disk:', err)
|
||||
}
|
||||
|
||||
if (restart) {
|
||||
// Check if there are any remaining ZIM download jobs before restarting
|
||||
const { QueueService } = await import('./queue_service.js')
|
||||
|
|
@ -272,7 +288,9 @@ export class ZimService {
|
|||
// Filter out completed jobs (progress === 100) to avoid race condition
|
||||
// where this job itself is still in the active queue
|
||||
const activeIncompleteJobs = activeJobs.filter((job) => {
|
||||
const progress = typeof job.progress === 'number' ? job.progress : 0
|
||||
const progress = typeof job.progress === 'object' && job.progress !== null
|
||||
? (job.progress as any).percent
|
||||
: typeof job.progress === 'number' ? job.progress : 0
|
||||
return progress < 100
|
||||
})
|
||||
|
||||
|
|
@ -283,15 +301,22 @@ export class ZimService {
|
|||
if (hasRemainingZimJobs) {
|
||||
logger.info('[ZimService] Skipping container restart - more ZIM downloads pending')
|
||||
} else {
|
||||
// Restart KIWIX container to pick up new ZIM file
|
||||
// If kiwix is already running in library mode, --monitorLibrary will pick up
|
||||
// the XML change automatically — no restart needed.
|
||||
const isLegacy = await this.dockerService.isKiwixOnLegacyConfig()
|
||||
if (!isLegacy) {
|
||||
logger.info('[ZimService] Kiwix is in library mode — XML updated, no container restart needed.')
|
||||
} else {
|
||||
// Legacy config: restart (affectContainer will trigger migration instead)
|
||||
logger.info('[ZimService] No more ZIM downloads pending - restarting KIWIX container')
|
||||
await this.dockerService
|
||||
.affectContainer(SERVICE_NAMES.KIWIX, 'restart')
|
||||
.catch((error) => {
|
||||
logger.error(`[ZimService] Failed to restart KIWIX container:`, error) // Don't stop the download completion, just log the error.
|
||||
logger.error(`[ZimService] Failed to restart KIWIX container:`, error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create InstalledResource entries for downloaded files
|
||||
for (const url of urls) {
|
||||
|
|
@ -347,6 +372,12 @@ export class ZimService {
|
|||
|
||||
await deleteFileIfExists(fullPath)
|
||||
|
||||
// Remove from kiwix library XML so --monitorLibrary stops serving the deleted file
|
||||
const kiwixLibraryService = new KiwixLibraryService()
|
||||
await kiwixLibraryService.removeBook(fileName).catch((err) => {
|
||||
logger.error(`[ZimService] Failed to remove ${fileName} from kiwix library:`, err)
|
||||
})
|
||||
|
||||
// Clean up InstalledResource entry
|
||||
const parsed = CollectionManifestService.parseZimFilename(fileName)
|
||||
if (parsed) {
|
||||
|
|
@ -458,7 +489,7 @@ export class ZimService {
|
|||
}
|
||||
|
||||
// Check if already downloading
|
||||
const existingJob = await RunDownloadJob.getByUrl(selectedOption.url)
|
||||
const existingJob = await RunDownloadJob.getActiveByUrl(selectedOption.url)
|
||||
if (existingJob) {
|
||||
return { success: false, message: 'Download already in progress' }
|
||||
}
|
||||
|
|
@ -497,6 +528,8 @@ export class ZimService {
|
|||
allowedMimeTypes: ZIM_MIME_TYPES,
|
||||
forceNew: true,
|
||||
filetype: 'zim',
|
||||
title: selectedOption.name,
|
||||
totalBytes: selectedOption.size_mb ? selectedOption.size_mb * 1024 * 1024 : undefined,
|
||||
})
|
||||
|
||||
if (!result || !result.job) {
|
||||
|
|
|
|||
|
|
@ -88,10 +88,29 @@ export async function doResumableDownload({
|
|||
let lastProgressTime = Date.now()
|
||||
let lastDownloadedBytes = startByte
|
||||
|
||||
// Stall detection: if no data arrives for 5 minutes, abort the download
|
||||
const STALL_TIMEOUT_MS = 5 * 60 * 1000
|
||||
let stallTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const clearStallTimer = () => {
|
||||
if (stallTimer) {
|
||||
clearTimeout(stallTimer)
|
||||
stallTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const resetStallTimer = () => {
|
||||
clearStallTimer()
|
||||
stallTimer = setTimeout(() => {
|
||||
cleanup(new Error('Download stalled - no data received for 5 minutes'))
|
||||
}, STALL_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
// Progress tracking stream to monitor data flow
|
||||
const progressStream = new Transform({
|
||||
transform(chunk: Buffer, _: any, callback: Function) {
|
||||
downloadedBytes += chunk.length
|
||||
resetStallTimer()
|
||||
|
||||
// Update progress tracking
|
||||
const now = Date.now()
|
||||
|
|
@ -118,6 +137,7 @@ export async function doResumableDownload({
|
|||
|
||||
// Handle errors and cleanup
|
||||
const cleanup = (error?: Error) => {
|
||||
clearStallTimer()
|
||||
progressStream.destroy()
|
||||
response.data.destroy()
|
||||
writeStream.destroy()
|
||||
|
|
@ -136,6 +156,7 @@ export async function doResumableDownload({
|
|||
})
|
||||
|
||||
writeStream.on('finish', async () => {
|
||||
clearStallTimer()
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
downloadedBytes,
|
||||
|
|
@ -151,7 +172,8 @@ export async function doResumableDownload({
|
|||
resolve(filepath)
|
||||
})
|
||||
|
||||
// Pipe: response -> progressStream -> writeStream
|
||||
// Start stall timer and pipe: response -> progressStream -> writeStream
|
||||
resetStallTimer()
|
||||
response.data.pipe(progressStream).pipe(writeStream)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { createReadStream } from 'fs'
|
|||
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 async function listDirectoryContents(path: string): Promise<FileEntry[]> {
|
||||
const entries = await readdir(path, { withFileTypes: true })
|
||||
|
|
@ -49,7 +50,7 @@ export async function listDirectoryContentsRecursive(path: string): Promise<File
|
|||
export async function ensureDirectoryExists(path: string): Promise<void> {
|
||||
try {
|
||||
await stat(path)
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
await mkdir(path, { recursive: true })
|
||||
}
|
||||
|
|
@ -73,7 +74,7 @@ export async function getFile(
|
|||
return createReadStream(path)
|
||||
}
|
||||
return await readFile(path)
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return null
|
||||
}
|
||||
|
|
@ -90,7 +91,7 @@ export async function getFileStatsIfExists(
|
|||
size: stats.size,
|
||||
modifiedTime: stats.mtime,
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return null
|
||||
}
|
||||
|
|
@ -101,7 +102,7 @@ export async function getFileStatsIfExists(
|
|||
export async function deleteFileIfExists(path: string): Promise<void> {
|
||||
try {
|
||||
await unlink(path)
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
|
|
@ -138,21 +139,20 @@ export function matchesDevice(fsPath: string, deviceName: string): boolean {
|
|||
// Remove /dev/ and /dev/mapper/ prefixes
|
||||
const normalized = fsPath.replace('/dev/mapper/', '').replace('/dev/', '')
|
||||
|
||||
// Direct match
|
||||
// Direct match (covers /dev/sda1 ↔ sda1, /dev/nvme0n1p1 ↔ nvme0n1p1)
|
||||
if (normalized === deviceName) {
|
||||
return true
|
||||
}
|
||||
|
||||
// LVM volumes use dashes instead of slashes
|
||||
// e.g., ubuntu--vg-ubuntu--lv matches the device name
|
||||
if (fsPath.includes(deviceName)) {
|
||||
// LVM/device-mapper: e.g., /dev/mapper/ubuntu--vg-ubuntu--lv contains "ubuntu--lv"
|
||||
if (fsPath.startsWith('/dev/mapper/') && fsPath.includes(deviceName)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function determineFileType(filename: string): 'image' | 'pdf' | 'text' | 'zim' | 'unknown' {
|
||||
export function determineFileType(filename: string): 'image' | 'pdf' | 'text' | 'epub' | 'zim' | 'unknown' {
|
||||
const ext = path.extname(filename).toLowerCase()
|
||||
if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp'].includes(ext)) {
|
||||
return 'image'
|
||||
|
|
@ -160,6 +160,8 @@ export function determineFileType(filename: string): 'image' | 'pdf' | 'text' |
|
|||
return 'pdf'
|
||||
} else if (['.txt', '.md', '.docx', '.rtf'].includes(ext)) {
|
||||
return 'text'
|
||||
} else if (ext === '.epub') {
|
||||
return 'epub'
|
||||
} else if (ext === '.zim') {
|
||||
return 'zim'
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ export function assertNotPrivateUrl(urlString: string): void {
|
|||
/^169\.254\.\d+\.\d+$/, // Link-local / cloud metadata
|
||||
/^\[::1\]$/,
|
||||
/^\[?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)
|
||||
]
|
||||
|
||||
if (blockedPatterns.some((re) => re.test(hostname))) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import vine from "@vinejs/vine";
|
||||
import { SETTINGS_KEYS } from "../../constants/kv_store.js";
|
||||
|
||||
export const getSettingSchema = vine.compile(vine.object({
|
||||
key: vine.enum(SETTINGS_KEYS),
|
||||
}))
|
||||
|
||||
export const updateSettingSchema = vine.compile(vine.object({
|
||||
key: vine.enum(SETTINGS_KEYS),
|
||||
|
|
|
|||
|
|
@ -61,12 +61,34 @@ export default class QueueWork extends BaseCommand {
|
|||
{
|
||||
connection: queueConfig.connection,
|
||||
concurrency: this.getConcurrencyForQueue(queueName),
|
||||
lockDuration: 300000,
|
||||
autorun: true,
|
||||
}
|
||||
)
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
// Required to prevent Node from treating BullMQ internal errors as unhandled
|
||||
// EventEmitter errors that crash the process.
|
||||
worker.on('error', (err) => {
|
||||
this.logger.error(`[${queueName}] Worker error: ${err.message}`)
|
||||
})
|
||||
|
||||
worker.on('failed', async (job, err) => {
|
||||
this.logger.error(`[${queueName}] Job failed: ${job?.id}, Error: ${err.message}`)
|
||||
|
||||
// If this was a Wikipedia download, mark it as failed in the DB
|
||||
if (job?.data?.filetype === 'zim' && job?.data?.url?.includes('wikipedia_en_')) {
|
||||
try {
|
||||
const { DockerService } = await import('#services/docker_service')
|
||||
const { ZimService } = await import('#services/zim_service')
|
||||
const dockerService = new DockerService()
|
||||
const zimService = new ZimService(dockerService)
|
||||
await zimService.onWikipediaDownloadComplete(job.data.url, false)
|
||||
} catch (e: any) {
|
||||
this.logger.error(
|
||||
`[${queueName}] Failed to update Wikipedia status: ${e.message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
worker.on('completed', (job) => {
|
||||
|
|
@ -81,6 +103,15 @@ export default class QueueWork extends BaseCommand {
|
|||
await CheckUpdateJob.scheduleNightly()
|
||||
await CheckServiceUpdatesJob.scheduleNightly()
|
||||
|
||||
// Safety net: log unhandled rejections instead of crashing the worker process.
|
||||
// Individual job errors are already caught by BullMQ; this catches anything that
|
||||
// escapes (e.g. a fire-and-forget promise in a callback that rejects unexpectedly).
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
this.logger.error(
|
||||
`Unhandled promise rejection in worker process: ${reason instanceof Error ? reason.message : String(reason)}`
|
||||
)
|
||||
})
|
||||
|
||||
// Graceful shutdown for all workers
|
||||
process.on('SIGTERM', async () => {
|
||||
this.logger.info('SIGTERM received. Shutting down workers...')
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ const bodyParserConfig = defineConfig({
|
|||
* Maximum limit of data to parse including all files
|
||||
* and fields
|
||||
*/
|
||||
limit: '20mb',
|
||||
limit: '110mb', // Set to 110MB to allow for some overhead beyond the 100MB file size limit
|
||||
types: ['multipart/form-data'],
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -13,7 +13,12 @@ const dbConfig = defineConfig({
|
|||
user: env.get('DB_USER'),
|
||||
password: env.get('DB_PASSWORD'),
|
||||
database: env.get('DB_DATABASE'),
|
||||
ssl: env.get('DB_SSL') ?? true, // Default to true
|
||||
ssl: env.get('DB_SSL') ? {} : false,
|
||||
},
|
||||
pool: {
|
||||
min: 2,
|
||||
max: 15,
|
||||
acquireTimeoutMillis: 10000, // Fail fast (10s) instead of silently hanging for ~60s
|
||||
},
|
||||
migrations: {
|
||||
naturalSort: true,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,12 @@ import { SystemService } from '#services/system_service'
|
|||
import { defineConfig } from '@adonisjs/inertia'
|
||||
import type { InferSharedProps } from '@adonisjs/inertia/types'
|
||||
|
||||
let _assistantNameCache: { value: string; expiresAt: number } | null = null
|
||||
|
||||
export function invalidateAssistantNameCache() {
|
||||
_assistantNameCache = null
|
||||
}
|
||||
|
||||
const inertiaConfig = defineConfig({
|
||||
/**
|
||||
* Path to the Edge view that will be used as the root view for Inertia responses
|
||||
|
|
@ -16,8 +22,14 @@ const inertiaConfig = defineConfig({
|
|||
appVersion: () => SystemService.getAppVersion(),
|
||||
environment: process.env.NODE_ENV || 'production',
|
||||
aiAssistantName: async () => {
|
||||
const now = Date.now()
|
||||
if (_assistantNameCache && now < _assistantNameCache.expiresAt) {
|
||||
return _assistantNameCache.value
|
||||
}
|
||||
const customName = await KVStore.getValue('ai.assistantCustomName')
|
||||
return (customName && customName.trim()) ? customName : 'AI Assistant'
|
||||
const value = (customName && customName.trim()) ? customName : 'AI Assistant'
|
||||
_assistantNameCache = { value, expiresAt: now + 60_000 }
|
||||
return value
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const loggerConfig = defineConfig({
|
|||
targets:
|
||||
targets()
|
||||
.pushIf(!app.inProduction, targets.pretty())
|
||||
.pushIf(app.inProduction, targets.file({ destination: "/app/storage/logs/admin.log" }))
|
||||
.pushIf(app.inProduction, targets.file({ destination: "/app/storage/logs/admin.log", mkdir: true }))
|
||||
.toArray(),
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { defineConfig } from '@adonisjs/transmit'
|
|||
import { redis } from '@adonisjs/transmit/transports'
|
||||
|
||||
export default defineConfig({
|
||||
pingInterval: false,
|
||||
pingInterval: '30s',
|
||||
transport: {
|
||||
driver: redis({
|
||||
host: env.get('REDIS_HOST'),
|
||||
|
|
|
|||
2
admin/constants/kiwix.ts
Normal file
2
admin/constants/kiwix.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
|
||||
export const KIWIX_LIBRARY_CMD = '--library /data/kiwix-library.xml --monitorLibrary --address=all'
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
import { KVStoreKey } from "../types/kv_store.js";
|
||||
|
||||
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'system.earlyAccess', 'ai.assistantCustomName'];
|
||||
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'ui.theme', 'system.earlyAccess', 'ai.assistantCustomName', 'ai.remoteOllamaUrl', 'ai.ollamaFlashAttention'];
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'services'
|
||||
|
||||
async up() {
|
||||
this.defer(async (db) => {
|
||||
await db
|
||||
.from(this.tableName)
|
||||
.where('service_name', 'nomad_kiwix_server')
|
||||
.whereRaw('`container_command` LIKE ?', ['%*.zim%'])
|
||||
.update({
|
||||
container_command: '--library /data/kiwix-library.xml --monitorLibrary --address=all',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.defer(async (db) => {
|
||||
await db
|
||||
.from(this.tableName)
|
||||
.where('service_name', 'nomad_kiwix_server')
|
||||
.where('container_command', '--library /data/kiwix-library.xml --monitorLibrary --address=all')
|
||||
.update({
|
||||
container_command: '*.zim --address=all',
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'map_markers'
|
||||
|
||||
async up() {
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.increments('id')
|
||||
table.string('name').notNullable()
|
||||
table.double('longitude').notNullable()
|
||||
table.double('latitude').notNullable()
|
||||
table.string('color', 20).notNullable().defaultTo('orange')
|
||||
table.string('marker_type', 20).notNullable().defaultTo('pin')
|
||||
table.string('route_id').nullable()
|
||||
table.integer('route_order').nullable()
|
||||
table.text('notes').nullable()
|
||||
table.timestamp('created_at')
|
||||
table.timestamp('updated_at')
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { BaseSeeder } from '@adonisjs/lucid/seeders'
|
|||
import { ModelAttributes } from '@adonisjs/lucid/types/model'
|
||||
import env from '#start/env'
|
||||
import { SERVICE_NAMES } from '../../constants/service_names.js'
|
||||
import { KIWIX_LIBRARY_CMD } from '../../constants/kiwix.js'
|
||||
|
||||
export default class ServiceSeeder extends BaseSeeder {
|
||||
// Use environment variable with fallback to production default
|
||||
|
|
@ -24,7 +25,7 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
icon: 'IconBooks',
|
||||
container_image: 'ghcr.io/kiwix/kiwix-serve:3.8.1',
|
||||
source_repo: 'https://github.com/kiwix/kiwix-tools',
|
||||
container_command: '*.zim --address=all',
|
||||
container_command: KIWIX_LIBRARY_CMD,
|
||||
container_config: JSON.stringify({
|
||||
HostConfig: {
|
||||
RestartPolicy: { Name: 'unless-stopped' },
|
||||
|
|
@ -70,7 +71,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.15.2',
|
||||
container_image: 'ollama/ollama:0.18.1',
|
||||
source_repo: 'https://github.com/ollama/ollama',
|
||||
container_command: 'serve',
|
||||
container_config: JSON.stringify({
|
||||
|
|
@ -94,7 +95,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.19.4',
|
||||
container_image: 'ghcr.io/gchq/cyberchef:10.22.1',
|
||||
source_repo: 'https://github.com/gchq/CyberChef',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
|
|
|
|||
200
admin/docs/api-reference.md
Normal file
200
admin/docs/api-reference.md
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
# API Reference
|
||||
|
||||
N.O.M.A.D. exposes a REST API for all operations. All endpoints are under `/api/` and return JSON.
|
||||
|
||||
---
|
||||
|
||||
## Conventions
|
||||
|
||||
**Base URL:** `http://<your-server>/api`
|
||||
|
||||
**Responses:**
|
||||
- Success responses include `{ "success": true }` and an HTTP 2xx status
|
||||
- Error responses return the appropriate HTTP status (400, 404, 409, 500) with an error message
|
||||
- Long-running operations (downloads, benchmarks, embeddings) return 201 or 202 with a job/benchmark ID for polling
|
||||
|
||||
**Async pattern:** Submit a job → receive an ID → poll a status endpoint until complete.
|
||||
|
||||
---
|
||||
|
||||
## Health
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/health` | Returns `{ "status": "ok" }` |
|
||||
|
||||
---
|
||||
|
||||
## System
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/system/info` | CPU, memory, disk, and platform info |
|
||||
| GET | `/api/system/internet-status` | Check internet connectivity |
|
||||
| GET | `/api/system/debug-info` | Detailed debug information |
|
||||
| GET | `/api/system/latest-version` | Check for the latest N.O.M.A.D. version |
|
||||
| POST | `/api/system/update` | Trigger a system update |
|
||||
| GET | `/api/system/update/status` | Get update progress |
|
||||
| GET | `/api/system/update/logs` | Get update operation logs |
|
||||
| GET | `/api/system/settings` | Get a setting value (query param: `key`) |
|
||||
| PATCH | `/api/system/settings` | Update a setting (`{ key, value }`) |
|
||||
| POST | `/api/system/subscribe-release-notes` | Subscribe an email to release notes |
|
||||
|
||||
### Services
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/system/services` | List all services with status |
|
||||
| POST | `/api/system/services/install` | Install a service |
|
||||
| POST | `/api/system/services/force-reinstall` | Force reinstall a service |
|
||||
| POST | `/api/system/services/affect` | Start, stop, or restart a service (body: `{ name, action }`) |
|
||||
| POST | `/api/system/services/check-updates` | Check for available service updates |
|
||||
| POST | `/api/system/services/update` | Update a service to a specific version |
|
||||
| GET | `/api/system/services/:name/available-versions` | List available versions for a service |
|
||||
|
||||
---
|
||||
|
||||
## AI Chat
|
||||
|
||||
### Models
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/ollama/models` | List available models (supports filtering, sorting, pagination) |
|
||||
| GET | `/api/ollama/installed-models` | List locally installed models |
|
||||
| POST | `/api/ollama/models` | Download a model (async, returns job) |
|
||||
| DELETE | `/api/ollama/models` | Delete an installed model |
|
||||
|
||||
### Chat
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/ollama/chat` | Send a chat message. Supports streaming (SSE) and RAG context injection. Body: `{ model, messages, stream?, useRag? }` |
|
||||
| GET | `/api/chat/suggestions` | Get suggested chat prompts |
|
||||
|
||||
### Remote Ollama
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/ollama/configure-remote` | Configure a remote Ollama or LM Studio instance |
|
||||
| GET | `/api/ollama/remote-status` | Check remote Ollama connection status |
|
||||
|
||||
### Chat Sessions
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/chat/sessions` | List all chat sessions |
|
||||
| POST | `/api/chat/sessions` | Create a new session |
|
||||
| GET | `/api/chat/sessions/:id` | Get a session with its messages |
|
||||
| PUT | `/api/chat/sessions/:id` | Update session metadata (title, etc.) |
|
||||
| DELETE | `/api/chat/sessions/:id` | Delete a session |
|
||||
| DELETE | `/api/chat/sessions/all` | Delete all sessions |
|
||||
| POST | `/api/chat/sessions/:id/messages` | Add a message to a session |
|
||||
|
||||
**Streaming:** The `/api/ollama/chat` endpoint supports Server-Sent Events (SSE) when `stream: true` is passed. Connect using `EventSource` or `fetch` with a streaming reader.
|
||||
|
||||
---
|
||||
|
||||
## Knowledge Base (RAG)
|
||||
|
||||
Upload documents to enable AI-powered retrieval during chat.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/rag/upload` | Upload a file for embedding (async, 202 response) |
|
||||
| GET | `/api/rag/files` | List stored RAG files |
|
||||
| DELETE | `/api/rag/files` | Delete a file (query param: `source`) |
|
||||
| GET | `/api/rag/active-jobs` | List active embedding jobs |
|
||||
| GET | `/api/rag/job-status` | Get status for a specific file embedding job |
|
||||
| GET | `/api/rag/failed-jobs` | List failed embedding jobs |
|
||||
| DELETE | `/api/rag/failed-jobs` | Clean up failed jobs and delete associated files |
|
||||
| POST | `/api/rag/sync` | Scan storage and sync database with filesystem |
|
||||
|
||||
---
|
||||
|
||||
## ZIM Files (Offline Content)
|
||||
|
||||
ZIM files provide offline Wikipedia, books, and other content via Kiwix.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/zim/list` | List locally stored ZIM files |
|
||||
| GET | `/api/zim/list-remote` | List remote ZIM files (paginated, supports search) |
|
||||
| GET | `/api/zim/curated-categories` | List curated categories with Essential/Standard/Comprehensive tiers |
|
||||
| POST | `/api/zim/download-remote` | Download a remote ZIM file (async) |
|
||||
| POST | `/api/zim/download-category-tier` | Download a full category tier |
|
||||
| DELETE | `/api/zim/:filename` | Delete a local ZIM file |
|
||||
|
||||
### Wikipedia
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/zim/wikipedia` | Get current Wikipedia selection state |
|
||||
| POST | `/api/zim/wikipedia/select` | Select a Wikipedia edition and tier |
|
||||
|
||||
---
|
||||
|
||||
## Maps
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/maps/regions` | List available map regions |
|
||||
| GET | `/api/maps/styles` | Get map styles JSON |
|
||||
| GET | `/api/maps/curated-collections` | List curated map collections |
|
||||
| POST | `/api/maps/fetch-latest-collections` | Fetch latest collection metadata from source |
|
||||
| POST | `/api/maps/download-base-assets` | Download base map assets |
|
||||
| POST | `/api/maps/download-remote` | Download a remote map file (async) |
|
||||
| POST | `/api/maps/download-remote-preflight` | Check download size/info before starting |
|
||||
| POST | `/api/maps/download-collection` | Download an entire collection by slug (async) |
|
||||
| DELETE | `/api/maps/:filename` | Delete a local map file |
|
||||
|
||||
---
|
||||
|
||||
## Downloads
|
||||
|
||||
Manage background download jobs for maps, ZIM files, and models.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/downloads/jobs` | List all download jobs |
|
||||
| GET | `/api/downloads/jobs/:filetype` | List jobs filtered by type (`zim`, `map`, etc.) |
|
||||
| DELETE | `/api/downloads/jobs/:jobId` | Cancel and remove a download job |
|
||||
|
||||
---
|
||||
|
||||
## Benchmarks
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/benchmark/run` | Run a benchmark (`full`, `system`, or `ai`; can be async) |
|
||||
| POST | `/api/benchmark/run/system` | Run system-only benchmark |
|
||||
| POST | `/api/benchmark/run/ai` | Run AI-only benchmark |
|
||||
| GET | `/api/benchmark/status` | Get current benchmark status (`idle` or `running`) |
|
||||
| GET | `/api/benchmark/results` | Get all benchmark results |
|
||||
| GET | `/api/benchmark/results/latest` | Get the most recent result |
|
||||
| GET | `/api/benchmark/results/:id` | Get a specific result |
|
||||
| POST | `/api/benchmark/submit` | Submit a result to the central repository |
|
||||
| POST | `/api/benchmark/builder-tag` | Update builder tag metadata for a result |
|
||||
| GET | `/api/benchmark/comparison` | Get comparison stats from the repository |
|
||||
| GET | `/api/benchmark/settings` | Get benchmark settings |
|
||||
| POST | `/api/benchmark/settings` | Update benchmark settings |
|
||||
|
||||
---
|
||||
|
||||
## Easy Setup & Content Updates
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/easy-setup/curated-categories` | List curated content categories for setup wizard |
|
||||
| POST | `/api/manifests/refresh` | Refresh manifest caches (`zim_categories`, `maps`, `wikipedia`) |
|
||||
| POST | `/api/content-updates/check` | Check for available collection updates |
|
||||
| POST | `/api/content-updates/apply` | Apply a single content update |
|
||||
| POST | `/api/content-updates/apply-all` | Apply multiple content updates |
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/docs/list` | List all available documentation files |
|
||||
|
|
@ -13,12 +13,12 @@ No — that's the whole point. Once your content is downloaded, everything works
|
|||
|
||||
### What hardware do I need?
|
||||
N.O.M.A.D. is designed for capable hardware, especially if you want to use the AI features. Recommended:
|
||||
- Modern multi-core CPU
|
||||
- Modern multi-core CPU (AMD Ryzen 7 with Radeon graphics is the community sweet spot)
|
||||
- 16GB+ RAM (32GB+ for best AI performance)
|
||||
- SSD storage (size depends on content — 500GB minimum, 2TB+ recommended)
|
||||
- SSD storage (size depends on content — 500GB minimum, 1TB+ recommended)
|
||||
- NVIDIA or AMD GPU recommended for faster AI responses
|
||||
|
||||
**For detailed build recommendations at three price points ($200–$800+), see the [Hardware Guide](https://www.projectnomad.us/hardware).**
|
||||
**For detailed build recommendations at three price points ($150–$1,000+), see the [Hardware Guide](https://www.projectnomad.us/hardware).**
|
||||
|
||||
### How much storage do I need?
|
||||
It depends on what you download:
|
||||
|
|
|
|||
|
|
@ -10,14 +10,14 @@ If this is your first time using N.O.M.A.D., the Easy Setup wizard will help you
|
|||
|
||||
**[Launch Easy Setup →](/easy-setup)**
|
||||
|
||||

|
||||

|
||||
|
||||
The wizard walks you through four simple steps:
|
||||
1. **Capabilities** — Choose what to enable: Information Library, AI Assistant, Education Platform, Maps, Data Tools, and Notes
|
||||
2. **Maps** — Select geographic regions for offline maps
|
||||
3. **Content** — Choose curated content collections with Essential, Standard, or Comprehensive tiers
|
||||
|
||||

|
||||

|
||||
4. **Review** — Confirm your selections and start downloading
|
||||
|
||||
Depending on what you selected, downloads may take a while. You can monitor progress in the Settings area, continue using features that are already installed, or leave your server running overnight for large downloads.
|
||||
|
|
@ -64,7 +64,7 @@ The Education Platform provides complete educational courses that work offline.
|
|||
|
||||
### AI Assistant — Built-in Chat
|
||||
|
||||

|
||||

|
||||
|
||||
N.O.M.A.D. includes a built-in AI chat interface powered by Ollama. It runs entirely on your server — no internet needed, no data sent anywhere.
|
||||
|
||||
|
|
@ -90,7 +90,7 @@ N.O.M.A.D. includes a built-in AI chat interface powered by Ollama. It runs enti
|
|||
|
||||
### Knowledge Base — Document-Aware AI
|
||||
|
||||

|
||||

|
||||
|
||||
The Knowledge Base lets you upload documents so the AI can reference them when answering your questions. It uses semantic search (RAG via Qdrant) to find relevant information from your uploaded files.
|
||||
|
||||
|
|
@ -115,7 +115,7 @@ The Knowledge Base lets you upload documents so the AI can reference them when a
|
|||
|
||||
### Maps — Offline Navigation
|
||||
|
||||

|
||||

|
||||
|
||||
View maps without internet. Download the regions you need before going offline.
|
||||
|
||||
|
|
@ -148,7 +148,7 @@ As your needs change, you can add more content anytime:
|
|||
|
||||
### Wikipedia Selector
|
||||
|
||||

|
||||

|
||||
|
||||
N.O.M.A.D. includes a dedicated Wikipedia content management tool for browsing and downloading Wikipedia packages.
|
||||
|
||||
|
|
@ -161,7 +161,7 @@ N.O.M.A.D. includes a dedicated Wikipedia content management tool for browsing a
|
|||
|
||||
### System Benchmark
|
||||
|
||||

|
||||

|
||||
|
||||
Test your hardware performance and see how your NOMAD build stacks up against the community.
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ Your personal offline knowledge server is ready to use.
|
|||
|
||||
Think of it as having Wikipedia, Khan Academy, an AI assistant, and offline maps all in one place, running on hardware you control.
|
||||
|
||||

|
||||

|
||||
|
||||
## What Can You Do?
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,97 @@
|
|||
# Release Notes
|
||||
|
||||
## Unreleased
|
||||
## Version 1.31.0 - April 3, 2026
|
||||
|
||||
### Features
|
||||
- **AI Assistant**: Added support for remote OpenAI-compatible hosts (e.g. Ollama, LM Studio, etc.) to support running models on seperate hardware from the Command Center host. Thanks @hestela for the contribution!
|
||||
- **AI Assistant**: Disabled Ollama Cloud support (not compatible with NOMAD's architecture) and added support for flash_attn to improve performance of compatible models. Thanks @hestela for the contribution!
|
||||
- **Information Library (Kiwix)**: The Kiwix container now uses an XML library file approach instead of a glob-based approach to inform the Kiwix container of available ZIM files. This allows for much more robust handling of ZIM files and avoids issues with the container failing to start due to incomplete/corrupt ZIM files being present in the storage directory. Thanks @jakeaturner for the contribution!
|
||||
- **RAG**: Added support for EPUB file embedding into the Knowledge Base. Thanks @arn6694 for the contribution!
|
||||
- **RAG**: Added support for multiple file uploads (<=5, 100mb each) to the Knowledge Base. Thanks @jakeaturner for the contribution!
|
||||
- **Maps**: Added support for customizable location markers on the map with database persistence. Thanks @chriscrosstalk for the contribution!
|
||||
- **Maps**: The global map file can now be downloaded directly from PMTiles for users who want to the full map and/or regions outside of the U.S. that haven't been added to the curated collections yet. Thanks @bgauger for the contribution!
|
||||
- **Maps**: Added a scale bar to the map viewer with imperial and metric options. Thanks @chriscrosstalk for the contribution!
|
||||
- **Downloads**: Added support/improvements for rich progress, friendly names, cancellation, and live status updates for active downloads in the UI. Thanks @chriscrosstalk for the contribution!
|
||||
- **UI**: Converted all PNGs to WEBP for reduced image sizes and improved performance. Thanks @hestela for the contribution!
|
||||
- **UI**: Added an Installed Models section to AI Assistant settings. Thanks @chriscrosstalk for the contribution!
|
||||
|
||||
### Bug Fixes
|
||||
- **Maps**: The maps API endpoints now properly check for "X-Forwarded-Proto" to support scenarios where the Command Center is behind a reverse proxy that terminates TLS. Thanks @davidgross for the fix!
|
||||
- **Maps**: Fixed an issue where the maps API endpoints could fail with an internal error if a hostname was used to access the Command Center instead of an IP address or localhost. Thanks @jakeaturner for the fix!
|
||||
- **Queue**: Increased the BullMQ lockDuration to prevent jobs from being killed prematurely on slower systems. Thanks @bgauger for the contribution!
|
||||
- **Queue**: Added better handling for very large downloads and user-initated cancellations. Thanks @bgauger for the contribution!
|
||||
- **Install**: The install script now checks for the presence of gpg (required for NVIDIA toolkit install) and automatically attempts to install it if it's missing. Thanks @chriscrosstalk for the fix!
|
||||
- **Security**: Added key validation to the settings read API endpoint. Thanks @LuisMIguelFurlanettoSousa for the fix!
|
||||
- **Security**: Improved URL validation logic for ZIM downloads to prevent SSRF vulnerabilities. Thanks @sebastiondev for the fix!
|
||||
- **UI**: Fixed the activity feed height in Easy Setup and added automatic scrolling to the latest message during installation. Thanks @chriscrosstalk for the contribution!
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Dependencies**: Updated various dependencies to close security vulnerabilities and improve stability
|
||||
- **Docker**: NOMAD now adds 'com.docker.compose.project': 'project-nomad-managed' and 'io.project-nomad.managed': 'true' labels to all containers installed via the Command Center to improve compatibility with other Docker management tools and make it easier to identify and manage NOMAD containers. Thanks @techyogi for the contribution!
|
||||
- **Docs**: Added a simple API reference for power users and developers. Thanks @hestela for the contribution!
|
||||
- **Docs**: Re-formatted the Quick Install command into multiple lines for better readability in the README. Thanks @samsara-02 for the contribution!
|
||||
- **Docs**: Updated the CONTRIBUTING and FAQ guides with the latest information and clarified some common questions. Thanks @jakeaturner for the contribution!
|
||||
- **Ops**: Bumped GitHub Actions to their latest versions. Thanks @salmanmkc for the contribution!
|
||||
- **Performance**: Shrunk the bundle size of the Command Center UI significantly by optimizing dependencies and tree-shaking, resulting in faster load times and a snappier user experience. Thanks @jakeaturner for the contribution!
|
||||
- **Performance**: Implemented gzip compression by default for all HTTP registered routes from the Command Center backend to further improve performance, especially on slower connections. The DISABLE_COMPRESSION environment variable can be used to turn off this feature if needed. Thanks @jakeaturner for the contribution!
|
||||
- **Performance**: Added light caching of certain Docker socket interactions and custom AI Assistant name resolution to improve performance and reduce redundant calls to the Docker API. Thanks @jakeaturner for the contribution!
|
||||
- **Performance**: Switched to Inertia router navigation calls where appropriate to take advantage of Inertia's built-in caching and performance optimizations for a smoother user experience. Thanks @jakeaturner for the contribution!
|
||||
|
||||
## Version 1.30.3 - March 25, 2026
|
||||
|
||||
### Features
|
||||
|
||||
### Bug Fixes
|
||||
- **Benchmark**: Fixed an issue where CPU and Disk Write scores could be displayed as 0 if the measured values was less than half of the reference mark. Thanks @bortlesboat for the fix!
|
||||
- **Content Manager**: Fixed a missing API client method that was causing ZIM file deletions to fail. Thanks @LuisMIguelFurlanettoSousa for the fix!
|
||||
- **Install**: Fixed an issue where the install script could incorrectly report the Docker NVIDIA runtime as missing. Thanks @brenex for the fix!
|
||||
- **Support the Project**: Fixed a broken link to Rogue Support. Thanks @chriscrosstalk for the fix!
|
||||
|
||||
### Improvements
|
||||
- **AI Assistant**: Improved error reporting and handling for model downloads. Thanks @chriscrosstalk for the contribution!
|
||||
- **AI Assistant**: Bumped the default version of Ollama installed to v0.18.1 to take advantage of the latest performance improvements and bug fixes.
|
||||
- **Apps**: Improved error reporting and handling for service installation failures. Thanks @trek-e for the contribution!
|
||||
- **Collections**: Updated various curated collection links to their latest versions. Thanks @builder555 for the contribution!
|
||||
- **Cyberchef**: Bumped the default version of CyberChef installed to v10.22.1 to take advantage of the latest features and bug fixes.
|
||||
- **Docs**: Added a link to the step-by-step installation guide and video tutorial. Thanks @chriscrosstalk for the contribution!
|
||||
- **Install**: Increased the retries limit for the MySQL service in Docker Compose to improve stability during installation on systems with slower performance. Thanks @dx4956 for the contribution!
|
||||
- **Install**: Fixed an issue where stale data could cause credentials mismatch in MySQL on reinstall. Thanks @chriscrosstalk for the fix!
|
||||
|
||||
## Version 1.30.0 - March 20, 2026
|
||||
|
||||
### Features
|
||||
- **Night Ops**: Added our most requested feature — a dark mode theme for the Command Center interface! Activate it from the footer and enjoy the sleek new look during your late-night missions. Thanks @chriscrosstalk for the contribution!
|
||||
- **Debug Info**: Added a new "Debug Info" modal accessible from the footer that provides detailed system and application information for troubleshooting and support. Thanks @chriscrosstalk for the contribution!
|
||||
- **Support the Project**: Added a new "Support the Project" page in settings with links to community resources, donation options, and ways to contribute.
|
||||
- **Install**: The main Nomad image is now fully self-contained and directly usable with Docker Compose, allowing for more flexible and customizable installations without relying on external scripts. The image remains fully backwards compatible with existing installations, and the install script has been updated to reflect the simpler deployment process.
|
||||
|
||||
### Bug Fixes
|
||||
- **Settings**: Storage usage display now prefers real block devices over tempfs. Thanks @Bortlesboat for the fix!
|
||||
- **Settings**: Fixed an issue where device matching and mount entry deduplication logic could cause incorrect storage usage reporting and missing devices in storage displays.
|
||||
- **Maps**: The Maps page now respects the request protocol (http vs https) to ensure map tiles load correctly. Thanks @davidgross for the bug report!
|
||||
- **Knowledge Base**: Fixed an issue where file embedding jobs could cause a retry storm if the Ollama service was unavailable. Thanks @skyam25 for the bug report!
|
||||
- **Curated Collections**: Fixed some broken links in the curated collections definitions (maps and ZIM files) that were causing some resources to fail to download.
|
||||
- **Easy Setup**: Fixed an issue where the "Start Here" badge would persist even after visiting the Easy Setup Wizard for the first time. Thanks @chriscrosstalk for the fix!
|
||||
- **UI**: Fixed an issue where the loading spinner could look strange in certain use cases.
|
||||
- **System Updates**: Fixed an issue where the update banner would persist even after the system was updated successfully. Thanks @chriscrosstalk for the fix!
|
||||
- **Performance**: Various small memory leak fixes and performance improvements across the UI to ensure a smoother experience.
|
||||
|
||||
### Improvements
|
||||
- **Ollama**: Improved GPU detection logic to ensure the latest GPU config is always passed to the Ollama container on update
|
||||
- **Ollama**: The detected GPU type is now persisted in the database for more reliable configuration and troubleshooting across updates and restarts. Thanks @chriscrosstalk for the contribution!
|
||||
- **Downloads**: Users can now dismiss failed download notifications to reduce clutter in the UI. Thanks @chriscrosstalk for the contribution!
|
||||
- **Logging**: Changed the default log level to "info" to reduce noise and focus on important messages. Thanks @traxeon for the suggestion!
|
||||
- **Logging**: Nomad's internal logger now creates it's own log directory on startup if it doesn't already exist to prevent errors on fresh installs where the logs directory hasn't been created yet.
|
||||
- **Dozzle**: Dozzle shell access and container actions are now disabled by default. Thanks @traxeon for the recommendation!
|
||||
- **MySQL & Redis**: Removed port exposure to host by default for improved security. Ports can still be exposed manually if needed. Thanks @traxeon for the recommendation!
|
||||
- **Dependencies**: Various dependency updates to close security vulnerabilities and improve stability
|
||||
- **Utility Scripts**: Added a check for the expected Docker Compose version (v2) in all utility scripts to provide clearer error messages and guidance if the environment is not set up correctly.
|
||||
- **Utility Scripts**: Added an additional warning to the installation script to inform about potential overwriting of existing customized configurations and the importance of backing up data before running the installation script again.
|
||||
- **Documentation**: Updated installation instructions to reflect the new option for manual deployment via Docker Compose without the install script.
|
||||
|
||||
|
||||
## Version 1.29.0 - March 11, 2026
|
||||
|
||||
### Features
|
||||
- **AI Assistant**: Added improved user guidance for troubleshooting GPU pass-through issues
|
||||
|
|
|
|||
|
|
@ -1,281 +0,0 @@
|
|||
# Project NOMAD Security Audit Report
|
||||
|
||||
**Date:** 2026-03-08
|
||||
**Version audited:** v1.28.0 (main branch)
|
||||
**Auditor:** Claude Code (automated + manual review)
|
||||
**Target:** Pre-launch security review
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Project NOMAD's codebase is **reasonably clean for a LAN appliance**, with no critical authentication bypasses or remote code execution vulnerabilities. However, there are **4 findings that should be fixed before public launch** — all are straightforward path traversal and SSRF issues with known fix patterns already used elsewhere in the codebase.
|
||||
|
||||
| Severity | Count | Summary |
|
||||
|----------|-------|---------|
|
||||
| **HIGH** | 4 | Path traversal (3), SSRF (1) |
|
||||
| **MEDIUM** | 5 | Dozzle shell, unvalidated settings read, content update URL injection, verbose errors, no rate limiting |
|
||||
| **LOW** | 5 | CSRF disabled, CORS wildcard, debug logging, npm dep CVEs, hardcoded HMAC |
|
||||
| **INFO** | 2 | No auth by design, Docker socket exposure by design |
|
||||
|
||||
---
|
||||
|
||||
## Scans Performed
|
||||
|
||||
| Scan | Tool | Result |
|
||||
|------|------|--------|
|
||||
| Dependency audit | `npm audit` | 2 CVEs (1 high, 1 moderate) |
|
||||
| Secret scan | Manual grep (passwords, keys, tokens, certs) | Clean — all secrets from env vars |
|
||||
| SAST | Semgrep (security-audit, OWASP, nodejs rulesets) | 0 findings (AdonisJS not in rulesets) |
|
||||
| Docker config review | Manual review of compose, Dockerfiles, scripts | 2 actionable findings |
|
||||
| Code review | Manual review of services, controllers, validators | 4 path traversal + 1 SSRF |
|
||||
| API endpoint audit | Manual review of all 60+ routes | Attack surface documented |
|
||||
| DAST (OWASP ZAP) | Skipped — Docker Desktop not running | Recommended as follow-up |
|
||||
|
||||
---
|
||||
|
||||
## FIX BEFORE LAUNCH
|
||||
|
||||
### 1. Path Traversal — ZIM File Delete (HIGH)
|
||||
|
||||
**File:** `admin/app/services/zim_service.ts:329-342`
|
||||
**Endpoint:** `DELETE /api/zim/:filename`
|
||||
|
||||
The `filename` parameter flows into `path.join()` with no directory containment check. An attacker can delete `.zim` files outside the storage directory:
|
||||
|
||||
```
|
||||
DELETE /api/zim/..%2F..%2Fsome-file.zim
|
||||
```
|
||||
|
||||
**Fix:** Resolve the full path and verify it starts with the expected storage directory:
|
||||
|
||||
```typescript
|
||||
async delete(file: string): Promise<void> {
|
||||
let fileName = file
|
||||
if (!fileName.endsWith('.zim')) {
|
||||
fileName += '.zim'
|
||||
}
|
||||
|
||||
const basePath = join(process.cwd(), ZIM_STORAGE_PATH)
|
||||
const fullPath = resolve(basePath, fileName)
|
||||
|
||||
// Prevent path traversal
|
||||
if (!fullPath.startsWith(basePath)) {
|
||||
throw new Error('Invalid filename')
|
||||
}
|
||||
|
||||
// ... rest of delete logic
|
||||
}
|
||||
```
|
||||
|
||||
This pattern is already used correctly in `rag_service.ts:deleteFileBySource()`.
|
||||
|
||||
---
|
||||
|
||||
### 2. Path Traversal — Map File Delete (HIGH)
|
||||
|
||||
**File:** `admin/app/services/map_service.ts` (delete method)
|
||||
**Endpoint:** `DELETE /api/maps/:filename`
|
||||
|
||||
Identical pattern to the ZIM delete. Same fix — resolve path, verify `startsWith(basePath)`.
|
||||
|
||||
---
|
||||
|
||||
### 3. Path Traversal — Documentation Read (HIGH)
|
||||
|
||||
**File:** `admin/app/services/docs_service.ts:61-83`
|
||||
**Endpoint:** `GET /docs/:slug`
|
||||
|
||||
The `slug` parameter flows into `path.join(this.docsPath, filename)` with no containment check. An attacker can read arbitrary `.md` files on the filesystem:
|
||||
|
||||
```
|
||||
GET /docs/..%2F..%2F..%2Fetc%2Fpasswd
|
||||
```
|
||||
|
||||
Limited by the mandatory `.md` extension, but could still read sensitive markdown files outside the docs directory (like CLAUDE.md, README.md, etc.).
|
||||
|
||||
**Fix:**
|
||||
|
||||
```typescript
|
||||
const basePath = this.docsPath
|
||||
const fullPath = path.resolve(basePath, filename)
|
||||
|
||||
if (!fullPath.startsWith(path.resolve(basePath))) {
|
||||
throw new Error('Invalid document slug')
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. SSRF — Download Endpoints (HIGH)
|
||||
|
||||
**File:** `admin/app/validators/common.ts`
|
||||
**Endpoints:** `POST /api/zim/download-remote`, `POST /api/maps/download-remote`, `POST /api/maps/download-base-assets`, `POST /api/maps/download-remote-preflight`
|
||||
|
||||
The download endpoints accept user-supplied URLs and the server fetches from them. Without validation, an attacker on the LAN (or via CSRF since `shield.ts` disables CSRF protection) could make NOMAD fetch from co-located services:
|
||||
- `http://localhost:3306` (MySQL)
|
||||
- `http://localhost:6379` (Redis)
|
||||
- `http://169.254.169.254/` (cloud metadata — if NOMAD is ever cloud-hosted)
|
||||
|
||||
**Fix:** Added `assertNotPrivateUrl()` that blocks loopback and link-local addresses before any download is initiated. Called in all download controllers.
|
||||
|
||||
**Scope note:** RFC1918 private addresses (10.x, 172.16-31.x, 192.168.x) are intentionally **allowed** because NOMAD is a LAN appliance and users may host content mirrors on their local network. The `require_tld: false` VineJS option is preserved so URLs like `http://my-nas:8080/file.zim` remain valid.
|
||||
|
||||
```typescript
|
||||
const blockedPatterns = [
|
||||
/^localhost$/,
|
||||
/^127\.\d+\.\d+\.\d+$/,
|
||||
/^0\.0\.0\.0$/,
|
||||
/^169\.254\.\d+\.\d+$/, // Link-local / cloud metadata
|
||||
/^\[::1\]$/,
|
||||
/^\[?fe80:/i, // IPv6 link-local
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FIX AFTER LAUNCH (Medium Priority)
|
||||
|
||||
### 5. Dozzle Web Shell Access (MEDIUM)
|
||||
|
||||
**File:** `install/management_compose.yaml:56`
|
||||
|
||||
```yaml
|
||||
- DOZZLE_ENABLE_SHELL=true
|
||||
```
|
||||
|
||||
Dozzle on port 9999 is bound to all interfaces with shell access enabled. Anyone on the LAN can open a web shell into containers, including `nomad_admin` which has the Docker socket mounted. This creates a path from "LAN access" → "container shell" → "Docker socket" → "host root."
|
||||
|
||||
**Fix:** Set `DOZZLE_ENABLE_SHELL=false`. Log viewing and container restart functionality are preserved.
|
||||
|
||||
---
|
||||
|
||||
### 6. Unvalidated Settings Key Read (MEDIUM)
|
||||
|
||||
**File:** `admin/app/controllers/settings_controller.ts`
|
||||
**Endpoint:** `GET /api/system/settings?key=...`
|
||||
|
||||
The `updateSetting` endpoint validates the key against an enum, but `getSetting` accepts any arbitrary key string. Currently harmless since the KV store only contains settings data, but could leak sensitive info if new keys are added.
|
||||
|
||||
**Fix:** Apply the same enum validation to the read endpoint.
|
||||
|
||||
---
|
||||
|
||||
### 7. Content Update URL Injection (MEDIUM)
|
||||
|
||||
**File:** `admin/app/validators/common.ts:72-88`
|
||||
**Endpoint:** `POST /api/content-updates/apply`
|
||||
|
||||
The `download_url` comes directly from the client request body. An attacker can supply any URL and NOMAD will download from it. The URL should be looked up server-side from the content manifest instead.
|
||||
|
||||
**Fix:** Validate `download_url` against the cached manifest, or apply the same loopback/link-local protections as finding #4 (already applied in this PR).
|
||||
|
||||
---
|
||||
|
||||
### 8. Verbose Error Messages (MEDIUM)
|
||||
|
||||
**Files:** `rag_controller.ts`, `docker_service.ts`, `system_update_service.ts`
|
||||
|
||||
Several controllers return raw `error.message` in API responses, potentially leaking internal paths, stack details, or Docker error messages to the client.
|
||||
|
||||
**Fix:** Return generic error messages in production. Log the details server-side.
|
||||
|
||||
---
|
||||
|
||||
### 9. No Rate Limiting (MEDIUM)
|
||||
|
||||
Zero rate limiting across all 60+ endpoints. While acceptable for a LAN appliance, some endpoints are particularly abusable:
|
||||
- `POST /api/benchmark/run` — spins up Docker containers for CPU/memory/disk stress tests
|
||||
- `POST /api/rag/upload` — file uploads (20MB limit per bodyparser config)
|
||||
- `POST /api/system/services/affect` — can stop/start any service repeatedly
|
||||
|
||||
**Fix:** Consider basic rate limiting on the benchmark and service control endpoints (e.g., 1 benchmark per minute, service actions throttled to prevent rapid cycling).
|
||||
|
||||
---
|
||||
|
||||
## LOW PRIORITY / ACCEPTED RISK
|
||||
|
||||
### 10. CSRF Protection Disabled (LOW)
|
||||
|
||||
**File:** `admin/config/shield.ts`
|
||||
|
||||
CSRF is disabled, meaning any website a LAN user visits could fire requests at NOMAD's API. This amplifies findings 1-4 — path traversal and SSRF could be triggered by a malicious webpage, not just direct LAN access.
|
||||
|
||||
**Assessment:** Acceptable for a LAN appliance with no auth system. Enabling CSRF would require significant auth/session infrastructure changes.
|
||||
|
||||
### 11. CORS Wildcard with Credentials (LOW)
|
||||
|
||||
**File:** `admin/config/cors.ts`
|
||||
|
||||
`origin: ['*']` with `credentials: true`. Standard for LAN appliances.
|
||||
|
||||
### 12. npm Dependency CVEs (LOW)
|
||||
|
||||
```
|
||||
tar <=7.5.9 HIGH Hardlink Path Traversal via Drive-Relative Linkpath
|
||||
ajv <6.14.0 MODERATE ReDoS when using $data option
|
||||
```
|
||||
|
||||
Both fixable via `npm audit fix`. Low practical risk since these are build/dev dependencies not directly exposed to user input.
|
||||
|
||||
**Fix:** Run `npm audit fix` and commit the updated lockfile.
|
||||
|
||||
### 13. Hardcoded HMAC Secret (LOW)
|
||||
|
||||
**File:** `admin/app/services/benchmark_service.ts:35`
|
||||
|
||||
The benchmark HMAC secret `'nomad-benchmark-v1-2026'` is hardcoded in open-source code. Anyone can forge leaderboard submissions.
|
||||
|
||||
**Assessment:** Accepted risk. The leaderboard has compensating controls (rate limiting, plausibility validation, hardware fingerprint dedup). The secret stops casual abuse, not determined attackers.
|
||||
|
||||
### 14. Production Debug Logging (LOW)
|
||||
|
||||
**File:** `install/management_compose.yaml:22`
|
||||
|
||||
```yaml
|
||||
LOG_LEVEL=debug
|
||||
```
|
||||
|
||||
Debug logging in production can expose internal state in log files.
|
||||
|
||||
**Fix:** Change to `LOG_LEVEL=info` for production compose template.
|
||||
|
||||
---
|
||||
|
||||
## INFORMATIONAL (By Design)
|
||||
|
||||
### No Authentication
|
||||
|
||||
All 60+ API endpoints are unauthenticated. This is by design — NOMAD is a LAN appliance and the network boundary is the access control. Issue #73 tracks the edge case of public IP interfaces.
|
||||
|
||||
### Docker Socket Exposure
|
||||
|
||||
The `nomad_admin` container mounts `/var/run/docker.sock`. This is necessary for NOMAD's core functionality (managing Docker containers). The socket is not exposed to the network — only the admin container can use it.
|
||||
|
||||
---
|
||||
|
||||
## Recommendations Summary
|
||||
|
||||
| Priority | Action | Effort |
|
||||
|----------|--------|--------|
|
||||
| **Before launch** | Fix 3 path traversals (ZIM delete, Map delete, Docs read) | ~30 min |
|
||||
| **Before launch** | Add SSRF protection to download URL validators | ~1 hour |
|
||||
| **Soon after** | Disable Dozzle shell access | 1 line change |
|
||||
| **Soon after** | Validate settings key on read endpoint | ~15 min |
|
||||
| **Soon after** | Sanitize error messages in responses | ~30 min |
|
||||
| **Nice to have** | Run `npm audit fix` | 5 min |
|
||||
| **Nice to have** | Change production log level to info | 1 line change |
|
||||
| **Follow-up** | OWASP ZAP dynamic scan against NOMAD3 | ~1 hour |
|
||||
|
||||
---
|
||||
|
||||
## What Went Right
|
||||
|
||||
- **No hardcoded secrets** — all credentials properly use environment variables
|
||||
- **No command injection** — Docker operations use the Docker API (dockerode), not shell commands
|
||||
- **No SQL injection** — all database queries use AdonisJS Lucid ORM with parameterized queries
|
||||
- **No eval/Function** — no dynamic code execution anywhere
|
||||
- **RAG service already has the correct fix pattern** — `deleteFileBySource()` uses `resolve()` + `startsWith()` for path containment
|
||||
- **Install script generates strong random passwords** — uses `/dev/urandom` for APP_KEY and DB passwords
|
||||
- **No privileged containers** — GPU passthrough uses DeviceRequests, not --privileged
|
||||
- **Health checks don't leak data** — internal-only calls
|
||||
|
|
@ -11,6 +11,7 @@ import { generateUUID } from '~/lib/util'
|
|||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import NotificationsProvider from '~/providers/NotificationProvider'
|
||||
import { ThemeProvider } from '~/providers/ThemeProvider'
|
||||
import { UsePageProps } from '../../types/system'
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'Project N.O.M.A.D.'
|
||||
|
|
@ -38,7 +39,8 @@ createInertiaApp({
|
|||
const showDevtools = ['development', 'staging'].includes(environment)
|
||||
createRoot(el).render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TransmitProvider baseUrl={window.location.origin} enableLogging={true}>
|
||||
<ThemeProvider>
|
||||
<TransmitProvider baseUrl={window.location.origin} enableLogging={environment === 'development'}>
|
||||
<NotificationsProvider>
|
||||
<ModalsProvider>
|
||||
<App {...props} />
|
||||
|
|
@ -46,6 +48,7 @@ createInertiaApp({
|
|||
</ModalsProvider>
|
||||
</NotificationsProvider>
|
||||
</TransmitProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,38 +1,262 @@
|
|||
import { useRef, useState, useCallback } from 'react'
|
||||
import useDownloads, { useDownloadsProps } from '~/hooks/useDownloads'
|
||||
import HorizontalBarChart from './HorizontalBarChart'
|
||||
import { extractFileName } from '~/lib/util'
|
||||
import { extractFileName, formatBytes } from '~/lib/util'
|
||||
import StyledSectionHeader from './StyledSectionHeader'
|
||||
import { IconAlertTriangle, IconX, IconLoader2 } from '@tabler/icons-react'
|
||||
import api from '~/lib/api'
|
||||
|
||||
interface ActiveDownloadProps {
|
||||
filetype?: useDownloadsProps['filetype']
|
||||
withHeader?: boolean
|
||||
}
|
||||
|
||||
function formatSpeed(bytesPerSec: number): string {
|
||||
if (bytesPerSec <= 0) return '0 B/s'
|
||||
if (bytesPerSec < 1024) return `${Math.round(bytesPerSec)} B/s`
|
||||
if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(1)} KB/s`
|
||||
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`
|
||||
}
|
||||
|
||||
type DownloadStatus = 'queued' | 'active' | 'stalled' | 'failed'
|
||||
|
||||
function getDownloadStatus(download: {
|
||||
progress: number
|
||||
lastProgressTime?: number
|
||||
status?: string
|
||||
}): DownloadStatus {
|
||||
if (download.status === 'failed') return 'failed'
|
||||
if (download.status === 'waiting' || download.status === 'delayed') return 'queued'
|
||||
// Fallback heuristic for model jobs and in-flight jobs from before this deploy
|
||||
if (download.progress === 0 && !download.lastProgressTime) return 'queued'
|
||||
if (download.lastProgressTime) {
|
||||
const elapsed = Date.now() - download.lastProgressTime
|
||||
if (elapsed > 60_000) return 'stalled'
|
||||
}
|
||||
return 'active'
|
||||
}
|
||||
|
||||
const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps) => {
|
||||
const { data: downloads } = useDownloads({ filetype })
|
||||
const { data: downloads, invalidate } = useDownloads({ filetype })
|
||||
const [cancellingJobs, setCancellingJobs] = useState<Set<string>>(new Set())
|
||||
const [confirmingCancel, setConfirmingCancel] = useState<string | null>(null)
|
||||
|
||||
// Track previous downloadedBytes for speed calculation
|
||||
const prevBytesRef = useRef<Map<string, { bytes: number; time: number }>>(new Map())
|
||||
const speedRef = useRef<Map<string, number[]>>(new Map())
|
||||
|
||||
const getSpeed = useCallback(
|
||||
(jobId: string, currentBytes?: number): number => {
|
||||
if (!currentBytes || currentBytes <= 0) return 0
|
||||
|
||||
const prev = prevBytesRef.current.get(jobId)
|
||||
const now = Date.now()
|
||||
|
||||
if (prev && prev.bytes > 0 && currentBytes > prev.bytes) {
|
||||
const deltaBytes = currentBytes - prev.bytes
|
||||
const deltaSec = (now - prev.time) / 1000
|
||||
if (deltaSec > 0) {
|
||||
const instantSpeed = deltaBytes / deltaSec
|
||||
|
||||
// Simple moving average (last 5 samples)
|
||||
const samples = speedRef.current.get(jobId) || []
|
||||
samples.push(instantSpeed)
|
||||
if (samples.length > 5) samples.shift()
|
||||
speedRef.current.set(jobId, samples)
|
||||
|
||||
const avg = samples.reduce((a, b) => a + b, 0) / samples.length
|
||||
prevBytesRef.current.set(jobId, { bytes: currentBytes, time: now })
|
||||
return avg
|
||||
}
|
||||
}
|
||||
|
||||
// Only set initial observation; never advance timestamp when bytes unchanged
|
||||
if (!prev) {
|
||||
prevBytesRef.current.set(jobId, { bytes: currentBytes, time: now })
|
||||
}
|
||||
return speedRef.current.get(jobId)?.at(-1) || 0
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleDismiss = async (jobId: string) => {
|
||||
await api.removeDownloadJob(jobId)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
const handleCancel = async (jobId: string) => {
|
||||
setCancellingJobs((prev) => new Set(prev).add(jobId))
|
||||
setConfirmingCancel(null)
|
||||
try {
|
||||
await api.cancelDownloadJob(jobId)
|
||||
// Clean up speed tracking refs
|
||||
prevBytesRef.current.delete(jobId)
|
||||
speedRef.current.delete(jobId)
|
||||
} finally {
|
||||
setCancellingJobs((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(jobId)
|
||||
return next
|
||||
})
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{withHeader && <StyledSectionHeader title="Active Downloads" className="mt-12 mb-4" />}
|
||||
<div className="space-y-4">
|
||||
{downloads && downloads.length > 0 ? (
|
||||
downloads.map((download) => (
|
||||
<div className="bg-desert-white rounded-lg p-4 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow">
|
||||
<HorizontalBarChart
|
||||
items={[
|
||||
{
|
||||
label: extractFileName(download.filepath) || download.url,
|
||||
value: download.progress,
|
||||
total: '100%',
|
||||
used: `${download.progress}%`,
|
||||
type: download.filetype,
|
||||
},
|
||||
]}
|
||||
downloads.map((download) => {
|
||||
const filename = extractFileName(download.filepath) || download.url
|
||||
const status = getDownloadStatus(download)
|
||||
const speed = getSpeed(download.jobId, download.downloadedBytes)
|
||||
const isCancelling = cancellingJobs.has(download.jobId)
|
||||
const isConfirming = confirmingCancel === download.jobId
|
||||
|
||||
return (
|
||||
<div
|
||||
key={download.jobId}
|
||||
className={`rounded-lg p-4 border shadow-sm hover:shadow-lg transition-shadow ${
|
||||
status === 'failed'
|
||||
? 'bg-surface-primary border-red-300'
|
||||
: 'bg-surface-primary border-default'
|
||||
}`}
|
||||
>
|
||||
{status === 'failed' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<IconAlertTriangle className="w-5 h-5 text-red-500 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">
|
||||
{download.title || filename}
|
||||
</p>
|
||||
{download.title && (
|
||||
<p className="text-xs text-text-muted truncate">{filename}</p>
|
||||
)}
|
||||
<p className="text-xs text-red-600 mt-0.5">
|
||||
Download failed{download.failedReason ? `: ${download.failedReason}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDismiss(download.jobId)}
|
||||
className="flex-shrink-0 p-1 rounded hover:bg-red-100 transition-colors"
|
||||
title="Dismiss failed download"
|
||||
>
|
||||
<IconX className="w-4 h-4 text-red-400 hover:text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{/* Title + Cancel button row */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-desert-green truncate">
|
||||
{download.title || filename}
|
||||
</p>
|
||||
{download.title && (
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-text-muted truncate font-mono">
|
||||
{filename}
|
||||
</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-desert-stone-lighter text-desert-stone-dark font-mono flex-shrink-0">
|
||||
{download.filetype}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!download.title && download.filetype && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-desert-stone-lighter text-desert-stone-dark font-mono">
|
||||
{download.filetype}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isConfirming ? (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleCancel(download.jobId)}
|
||||
className="text-xs px-2 py-1 rounded bg-red-100 text-red-700 hover:bg-red-200 transition-colors"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmingCancel(null)}
|
||||
className="text-xs px-2 py-1 rounded bg-desert-stone-lighter text-text-muted hover:bg-desert-stone-light transition-colors"
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
</div>
|
||||
) : isCancelling ? (
|
||||
<IconLoader2 className="w-4 h-4 text-text-muted animate-spin flex-shrink-0" />
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmingCancel(download.jobId)}
|
||||
className="flex-shrink-0 p-1 rounded hover:bg-red-100 transition-colors"
|
||||
title="Cancel download"
|
||||
>
|
||||
<IconX className="w-4 h-4 text-text-muted hover:text-red-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Size info */}
|
||||
<div className="flex justify-between items-baseline text-sm text-text-muted font-mono">
|
||||
<span>
|
||||
{download.downloadedBytes && download.totalBytes
|
||||
? `${formatBytes(download.downloadedBytes, 1)} / ${formatBytes(download.totalBytes, 1)}`
|
||||
: `${download.progress}% / 100%`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="relative">
|
||||
<div className="h-6 bg-desert-green-lighter bg-opacity-20 rounded-lg border border-default overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-lg transition-all duration-1000 ease-out bg-desert-green"
|
||||
style={{ width: `${download.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
<div
|
||||
className={`absolute top-1/2 -translate-y-1/2 font-bold text-xs ${
|
||||
download.progress > 15
|
||||
? 'left-2 text-white drop-shadow-md'
|
||||
: 'right-2 text-desert-green'
|
||||
}`}
|
||||
>
|
||||
{Math.round(download.progress)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
{status === 'queued' && (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-desert-stone" />
|
||||
<span className="text-xs text-text-muted">Waiting...</span>
|
||||
</>
|
||||
)}
|
||||
{status === 'active' && (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
<span className="text-xs text-text-muted">
|
||||
Downloading...{speed > 0 ? ` ${formatSpeed(speed)}` : ''}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{status === 'stalled' && download.lastProgressTime && (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse" />
|
||||
<span className="text-xs text-orange-600">
|
||||
No data received for{' '}
|
||||
{Math.floor((Date.now() - download.lastProgressTime) / 60_000)}m...
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<p className="text-gray-500">No active downloads</p>
|
||||
<p className="text-text-muted">No active downloads</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ const ActiveEmbedJobs = ({ withHeader = false }: ActiveEmbedJobsProps) => {
|
|||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500">No files are currently being processed</p>
|
||||
<p className="text-text-muted">No files are currently being processed</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import useOllamaModelDownloads from '~/hooks/useOllamaModelDownloads'
|
||||
import HorizontalBarChart from './HorizontalBarChart'
|
||||
import StyledSectionHeader from './StyledSectionHeader'
|
||||
import { IconAlertTriangle } from '@tabler/icons-react'
|
||||
|
||||
interface ActiveModelDownloadsProps {
|
||||
withHeader?: boolean
|
||||
|
|
@ -17,8 +18,19 @@ const ActiveModelDownloads = ({ withHeader = false }: ActiveModelDownloadsProps)
|
|||
downloads.map((download) => (
|
||||
<div
|
||||
key={download.model}
|
||||
className="bg-desert-white rounded-lg p-4 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow"
|
||||
className={`bg-desert-white rounded-lg p-4 border shadow-sm hover:shadow-lg transition-shadow ${
|
||||
download.error ? 'border-red-400' : 'border-desert-stone-light'
|
||||
}`}
|
||||
>
|
||||
{download.error ? (
|
||||
<div className="flex items-start gap-3">
|
||||
<IconAlertTriangle className="text-red-500 flex-shrink-0 mt-0.5" size={20} />
|
||||
<div>
|
||||
<p className="font-medium text-text-primary">{download.model}</p>
|
||||
<p className="text-sm text-red-600 mt-1">{download.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<HorizontalBarChart
|
||||
items={[
|
||||
{
|
||||
|
|
@ -30,10 +42,11 @@ const ActiveModelDownloads = ({ withHeader = false }: ActiveModelDownloadsProps)
|
|||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500">No active model downloads</p>
|
||||
<p className="text-text-muted">No active model downloads</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import * as Icons from '@tabler/icons-react'
|
||||
import classNames from '~/lib/classNames'
|
||||
import DynamicIcon from './DynamicIcon'
|
||||
import DynamicIcon, { DynamicIconName } from './DynamicIcon'
|
||||
import StyledButton, { StyledButtonProps } from './StyledButton'
|
||||
|
||||
export type AlertProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
|
|
@ -10,7 +9,7 @@ export type AlertProps = React.HTMLAttributes<HTMLDivElement> & {
|
|||
children?: React.ReactNode
|
||||
dismissible?: boolean
|
||||
onDismiss?: () => void
|
||||
icon?: keyof typeof Icons
|
||||
icon?: DynamicIconName
|
||||
variant?: 'standard' | 'bordered' | 'solid'
|
||||
buttonProps?: StyledButtonProps
|
||||
}
|
||||
|
|
@ -27,7 +26,7 @@ export default function Alert({
|
|||
buttonProps,
|
||||
...props
|
||||
}: AlertProps) {
|
||||
const getDefaultIcon = (): keyof typeof Icons => {
|
||||
const getDefaultIcon = (): DynamicIconName => {
|
||||
switch (type) {
|
||||
case 'warning':
|
||||
return 'IconAlertTriangle'
|
||||
|
|
@ -43,7 +42,7 @@ export default function Alert({
|
|||
}
|
||||
|
||||
const getIconColor = () => {
|
||||
if (variant === 'solid') return 'text-desert-white'
|
||||
if (variant === 'solid') return 'text-white'
|
||||
switch (type) {
|
||||
case 'warning':
|
||||
return 'text-desert-orange'
|
||||
|
|
@ -81,15 +80,15 @@ export default function Alert({
|
|||
case 'solid':
|
||||
variantStyles.push(
|
||||
type === 'warning'
|
||||
? 'bg-desert-orange text-desert-white border border-desert-orange-dark'
|
||||
? 'bg-desert-orange text-white border border-desert-orange-dark'
|
||||
: type === 'error'
|
||||
? 'bg-desert-red text-desert-white border border-desert-red-dark'
|
||||
? 'bg-desert-red text-white border border-desert-red-dark'
|
||||
: type === 'success'
|
||||
? 'bg-desert-olive text-desert-white border border-desert-olive-dark'
|
||||
? 'bg-desert-olive text-white border border-desert-olive-dark'
|
||||
: type === 'info'
|
||||
? 'bg-desert-green text-desert-white border border-desert-green-dark'
|
||||
? 'bg-desert-green text-white border border-desert-green-dark'
|
||||
: type === 'info-inverted'
|
||||
? 'bg-desert-tan text-desert-white border border-desert-tan-dark'
|
||||
? 'bg-desert-tan text-white border border-desert-tan-dark'
|
||||
: ''
|
||||
)
|
||||
return classNames(baseStyles, 'shadow-lg', ...variantStyles)
|
||||
|
|
@ -112,7 +111,7 @@ export default function Alert({
|
|||
}
|
||||
|
||||
const getTitleColor = () => {
|
||||
if (variant === 'solid') return 'text-desert-white'
|
||||
if (variant === 'solid') return 'text-white'
|
||||
|
||||
switch (type) {
|
||||
case 'warning':
|
||||
|
|
@ -131,7 +130,7 @@ export default function Alert({
|
|||
}
|
||||
|
||||
const getMessageColor = () => {
|
||||
if (variant === 'solid') return 'text-desert-white text-opacity-90'
|
||||
if (variant === 'solid') return 'text-white text-opacity-90'
|
||||
|
||||
switch (type) {
|
||||
case 'warning':
|
||||
|
|
@ -149,7 +148,7 @@ export default function Alert({
|
|||
|
||||
const getCloseButtonStyles = () => {
|
||||
if (variant === 'solid') {
|
||||
return 'text-desert-white hover:text-desert-white hover:bg-black hover:bg-opacity-20'
|
||||
return 'text-white hover:text-white hover:bg-black hover:bg-opacity-20'
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
|
|
|
|||
|
|
@ -9,18 +9,18 @@ interface BouncingDotsProps {
|
|||
export default function BouncingDots({ text, containerClassName, textClassName }: BouncingDotsProps) {
|
||||
return (
|
||||
<div className={clsx("flex items-center justify-center gap-2", containerClassName)}>
|
||||
<span className={clsx("text-gray-600", textClassName)}>{text}</span>
|
||||
<span className={clsx("text-text-secondary", textClassName)}>{text}</span>
|
||||
<span className="flex gap-1 mt-1">
|
||||
<span
|
||||
className="w-1.5 h-1.5 bg-gray-600 rounded-full animate-bounce"
|
||||
className="w-1.5 h-1.5 bg-text-secondary rounded-full animate-bounce"
|
||||
style={{ animationDelay: '0ms' }}
|
||||
/>
|
||||
<span
|
||||
className="w-1.5 h-1.5 bg-gray-600 rounded-full animate-bounce"
|
||||
className="w-1.5 h-1.5 bg-text-secondary rounded-full animate-bounce"
|
||||
style={{ animationDelay: '150ms' }}
|
||||
/>
|
||||
<span
|
||||
className="w-1.5 h-1.5 bg-gray-600 rounded-full animate-bounce"
|
||||
className="w-1.5 h-1.5 bg-text-secondary rounded-full animate-bounce"
|
||||
style={{ animationDelay: '300ms' }}
|
||||
/>
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ const FadingImage = ({ alt = "Fading image", className = "" }) => {
|
|||
isVisible ? 'opacity-100' : 'opacity-0'
|
||||
}`}>
|
||||
<img
|
||||
src={`/project_nomad_logo.png`}
|
||||
src={`/project_nomad_logo.webp`}
|
||||
alt={alt}
|
||||
className={`w-64 h-64 ${className}`}
|
||||
/>
|
||||
|
|
|
|||
103
admin/inertia/components/DebugInfoModal.tsx
Normal file
103
admin/inertia/components/DebugInfoModal.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { IconBug, IconCopy, IconCheck } from '@tabler/icons-react'
|
||||
import StyledModal from './StyledModal'
|
||||
import api from '~/lib/api'
|
||||
|
||||
interface DebugInfoModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function DebugInfoModal({ open, onClose }: DebugInfoModalProps) {
|
||||
const [debugText, setDebugText] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
setLoading(true)
|
||||
setCopied(false)
|
||||
|
||||
api.getDebugInfo().then((text) => {
|
||||
if (text) {
|
||||
const browserLine = `Browser: ${navigator.userAgent}`
|
||||
setDebugText(text + '\n' + browserLine)
|
||||
} else {
|
||||
setDebugText('Failed to load debug info. Please try again.')
|
||||
}
|
||||
setLoading(false)
|
||||
}).catch(() => {
|
||||
setDebugText('Failed to load debug info. Please try again.')
|
||||
setLoading(false)
|
||||
})
|
||||
}, [open])
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(debugText)
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
const textarea = document.querySelector<HTMLTextAreaElement>('#debug-info-text')
|
||||
if (textarea) {
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
}
|
||||
}
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Debug Info"
|
||||
icon={<IconBug className="size-8 text-desert-green" />}
|
||||
cancelText="Close"
|
||||
onCancel={onClose}
|
||||
>
|
||||
<p className="text-sm text-gray-500 mb-3 text-left">
|
||||
This is non-sensitive system info you can share when reporting issues.
|
||||
No passwords, IPs, or API keys are included.
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
id="debug-info-text"
|
||||
readOnly
|
||||
value={loading ? 'Loading...' : debugText}
|
||||
rows={18}
|
||||
className="w-full font-mono text-xs text-black bg-gray-50 border border-gray-200 rounded-md p-3 resize-none focus:outline-none text-left"
|
||||
/>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-desert-green px-3 py-1.5 text-sm font-semibold text-white hover:bg-desert-green-dark transition-colors disabled:opacity-50"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<IconCheck className="size-4" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconCopy className="size-4" />
|
||||
Copy to Clipboard
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<a
|
||||
href="https://github.com/Crosstalk-Solutions/project-nomad/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-desert-green hover:underline"
|
||||
>
|
||||
Open a GitHub Issue
|
||||
</a>
|
||||
</div>
|
||||
</StyledModal>
|
||||
)
|
||||
}
|
||||
|
|
@ -63,7 +63,7 @@ const DownloadURLModal: React.FC<DownloadURLModalProps> = ({
|
|||
large
|
||||
>
|
||||
<div className="flex flex-col pb-4">
|
||||
<p className="text-gray-700 mb-8">
|
||||
<p className="text-text-secondary mb-8">
|
||||
Enter the URL of the map region file you wish to download. The URL must be publicly
|
||||
reachable and end with .pmtiles. A preflight check will be run to verify the file's
|
||||
availability, type, and approximate size.
|
||||
|
|
@ -76,11 +76,11 @@ const DownloadURLModal: React.FC<DownloadURLModalProps> = ({
|
|||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
<div className="min-h-24 max-h-96 overflow-y-auto bg-gray-50 p-4 rounded border border-gray-300 text-left">
|
||||
<div className="min-h-24 max-h-96 overflow-y-auto bg-surface-secondary p-4 rounded border border-border-default text-left">
|
||||
{messages.map((message, idx) => (
|
||||
<p
|
||||
key={idx}
|
||||
className="text-sm text-gray-900 font-mono leading-relaxed break-words mb-3"
|
||||
className="text-sm text-text-primary font-mono leading-relaxed break-words mb-3"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,36 +1,26 @@
|
|||
import classNames from 'classnames'
|
||||
import * as TablerIcons from '@tabler/icons-react'
|
||||
import { icons } from '../lib/icons'
|
||||
|
||||
export type DynamicIconName = keyof typeof TablerIcons
|
||||
export type { DynamicIconName } from '../lib/icons'
|
||||
|
||||
interface DynamicIconProps {
|
||||
icon?: DynamicIconName
|
||||
icon?: keyof typeof icons
|
||||
className?: string
|
||||
stroke?: number
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a dynamic icon from the TablerIcons library based on the provided icon name.
|
||||
* @param icon - The name of the icon to render.
|
||||
* @param className - Optional additional CSS classes to apply to the icon.
|
||||
* @param stroke - Optional stroke width for the icon.
|
||||
* @returns A React element representing the icon, or null if no matching icon is found.
|
||||
*/
|
||||
const DynamicIcon: React.FC<DynamicIconProps> = ({ icon, className, stroke, onClick }) => {
|
||||
if (!icon) return null
|
||||
|
||||
const Icon = TablerIcons[icon]
|
||||
const Icon = icons[icon]
|
||||
|
||||
if (!Icon) {
|
||||
console.warn(`Icon "${icon}" not found in TablerIcons.`)
|
||||
console.warn(`Icon "${icon}" not found in icon map.`)
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
<Icon className={classNames('h-5 w-5', className)} stroke={stroke || 2} onClick={onClick} />
|
||||
)
|
||||
return <Icon className={classNames('h-5 w-5', className)} strokeWidth={stroke ?? 2} onClick={onClick} />
|
||||
}
|
||||
|
||||
export default DynamicIcon
|
||||
|
|
|
|||
|
|
@ -1,15 +1,31 @@
|
|||
import { useState } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import { UsePageProps } from '../../types/system'
|
||||
import ThemeToggle from '~/components/ThemeToggle'
|
||||
import { IconBug } from '@tabler/icons-react'
|
||||
import DebugInfoModal from './DebugInfoModal'
|
||||
|
||||
export default function Footer() {
|
||||
const { appVersion } = usePage().props as unknown as UsePageProps
|
||||
const [debugModalOpen, setDebugModalOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<footer className="">
|
||||
<div className="flex justify-center border-t border-gray-900/10 py-4">
|
||||
<p className="text-sm/6 text-gray-600">
|
||||
<footer>
|
||||
<div className="flex items-center justify-center gap-3 border-t border-border-subtle py-4">
|
||||
<p className="text-sm/6 text-text-secondary">
|
||||
Project N.O.M.A.D. Command Center v{appVersion}
|
||||
</p>
|
||||
<span className="text-gray-300">|</span>
|
||||
<button
|
||||
onClick={() => setDebugModalOpen(true)}
|
||||
className="text-sm/6 text-gray-500 hover:text-desert-green flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
<IconBug className="size-3.5" />
|
||||
Debug Info
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<DebugInfoModal open={debugModalOpen} onClose={() => setDebugModalOpen(false)} />
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export default function HorizontalBarChart({
|
|||
className={classNames(
|
||||
'absolute top-1/2 -translate-y-1/2 font-bold text-sm',
|
||||
item.value > 15
|
||||
? 'left-3 text-desert-white drop-shadow-md'
|
||||
? 'left-3 text-white drop-shadow-md'
|
||||
: 'right-3 text-desert-green'
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { IconCircleCheck, IconCircleX } from '@tabler/icons-react'
|
||||
import classNames from '~/lib/classNames'
|
||||
|
||||
|
|
@ -12,16 +13,30 @@ export type InstallActivityFeedProps = {
|
|||
| 'created'
|
||||
| 'preinstall'
|
||||
| 'preinstall-complete'
|
||||
| 'preinstall-error'
|
||||
| 'starting'
|
||||
| 'started'
|
||||
| 'finalizing'
|
||||
| 'completed'
|
||||
| 'checking-dependencies'
|
||||
| 'dependency-installed'
|
||||
| 'image-exists'
|
||||
| 'gpu-config'
|
||||
| 'stopping'
|
||||
| 'removing'
|
||||
| 'recreating'
|
||||
| 'cleanup-warning'
|
||||
| 'no-volumes'
|
||||
| 'volume-removed'
|
||||
| 'volume-cleanup-warning'
|
||||
| 'error'
|
||||
| 'update-pulling'
|
||||
| 'update-stopping'
|
||||
| 'update-creating'
|
||||
| 'update-starting'
|
||||
| 'update-complete'
|
||||
| 'update-rollback'
|
||||
| (string & {})
|
||||
timestamp: string
|
||||
message: string
|
||||
}>
|
||||
|
|
@ -30,10 +45,18 @@ export type InstallActivityFeedProps = {
|
|||
}
|
||||
|
||||
const InstallActivityFeed: React.FC<InstallActivityFeedProps> = ({ activity, className, withHeader = false }) => {
|
||||
const listRef = useRef<HTMLUListElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (listRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight
|
||||
}
|
||||
}, [activity])
|
||||
|
||||
return (
|
||||
<div className={classNames('bg-white shadow-sm rounded-lg p-6', className)}>
|
||||
{withHeader && <h2 className="text-lg font-semibold text-gray-900">Installation Activity</h2>}
|
||||
<ul role="list" className={classNames("space-y-6 text-desert-green", withHeader ? 'mt-6' : '')}>
|
||||
<div className={classNames('bg-surface-primary shadow-sm rounded-lg p-6', className)}>
|
||||
{withHeader && <h2 className="text-lg font-semibold text-text-primary">Installation Activity</h2>}
|
||||
<ul ref={listRef} role="list" className={classNames("space-y-6 text-desert-green max-h-[400px] overflow-y-auto scroll-smooth", withHeader ? 'mt-6' : '')}>
|
||||
{activity.map((activityItem, activityItemIdx) => (
|
||||
<li key={activityItem.timestamp} className="relative flex gap-x-4">
|
||||
<div
|
||||
|
|
@ -42,25 +65,25 @@ const InstallActivityFeed: React.FC<InstallActivityFeedProps> = ({ activity, cla
|
|||
'absolute left-0 top-0 flex w-6 justify-center'
|
||||
)}
|
||||
>
|
||||
<div className="w-px bg-gray-200" />
|
||||
<div className="w-px bg-border-subtle" />
|
||||
</div>
|
||||
<>
|
||||
<div className="relative flex size-6 flex-none items-center justify-center bg-transparent">
|
||||
{activityItem.type === 'completed' || activityItem.type === 'update-complete' ? (
|
||||
<IconCircleCheck aria-hidden="true" className="size-6 text-indigo-600" />
|
||||
) : activityItem.type === 'update-rollback' ? (
|
||||
) : activityItem.type === 'error' || activityItem.type === 'update-rollback' || activityItem.type === 'preinstall-error' ? (
|
||||
<IconCircleX aria-hidden="true" className="size-6 text-red-500" />
|
||||
) : (
|
||||
<div className="size-1.5 rounded-full bg-gray-100 ring-1 ring-gray-300" />
|
||||
<div className="size-1.5 rounded-full bg-surface-secondary ring-1 ring-border-default" />
|
||||
)}
|
||||
</div>
|
||||
<p className="flex-auto py-0.5 text-xs/5 text-gray-500">
|
||||
<span className="font-semibold text-gray-900">{activityItem.service_name}</span> -{' '}
|
||||
{activityItem.type.charAt(0).toUpperCase() + activityItem.type.slice(1)}
|
||||
<p className="flex-auto py-0.5 text-xs/5 text-text-muted">
|
||||
<span className="font-semibold text-text-primary">{activityItem.service_name}</span> -{' '}
|
||||
{activityItem.message || activityItem.type.charAt(0).toUpperCase() + activityItem.type.slice(1)}
|
||||
</p>
|
||||
<time
|
||||
dateTime={activityItem.timestamp}
|
||||
className="flex-none py-0.5 text-xs/5 text-gray-500"
|
||||
className="flex-none py-0.5 text-xs/5 text-text-muted"
|
||||
>
|
||||
{activityItem.timestamp}
|
||||
</time>
|
||||
|
|
|
|||
|
|
@ -15,12 +15,12 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
|||
}) => {
|
||||
if (!fullscreen) {
|
||||
return (
|
||||
<div className={`flex flex-col items-center justify-center ${className}`}>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div
|
||||
className={`w-8 h-8 border-[3px] ${light ? 'border-white' : 'border-slate-400'} border-t-transparent rounded-full animate-spin`}
|
||||
className={`w-8 h-8 border-[3px] ${light ? 'border-white' : 'border-text-muted'} border-t-transparent rounded-full animate-spin ${className || ''}`}
|
||||
></div>
|
||||
{!iconOnly && (
|
||||
<div className={light ? 'text-white mt-2' : 'text-slate-800 mt-2'}>
|
||||
<div className={light ? 'text-white mt-2' : 'text-text-primary mt-2'}>
|
||||
{text || 'Loading...'}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -9,14 +9,14 @@ const ProgressBar = ({ progress, speed }: { progress: number; speed?: string })
|
|||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="relative w-full h-2 bg-gray-200 rounded">
|
||||
<div className="relative w-full h-2 bg-border-subtle rounded">
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full bg-desert-green rounded"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
{speed && (
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
<div className="mt-1 text-sm text-text-muted">
|
||||
Est. Speed: {speed}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ export default function StorageProjectionBar({
|
|||
className={classNames(
|
||||
'absolute top-1/2 -translate-y-1/2 font-bold text-sm',
|
||||
projectedTotalPercent > 15
|
||||
? 'left-3 text-desert-white drop-shadow-md'
|
||||
? 'left-3 text-white drop-shadow-md'
|
||||
: 'right-3 text-desert-green'
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -56,9 +56,9 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
|||
switch (variant) {
|
||||
case 'primary':
|
||||
return clsx(
|
||||
'bg-desert-green text-desert-white',
|
||||
'hover:bg-desert-green-dark hover:shadow-lg',
|
||||
'active:bg-desert-green-darker',
|
||||
'bg-desert-green text-white',
|
||||
'hover:bg-btn-green-hover hover:shadow-lg',
|
||||
'active:bg-btn-green-active',
|
||||
'disabled:bg-desert-green-light disabled:text-desert-stone-light',
|
||||
baseTransition,
|
||||
baseHover
|
||||
|
|
@ -66,7 +66,7 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
|||
|
||||
case 'secondary':
|
||||
return clsx(
|
||||
'bg-desert-tan text-desert-white',
|
||||
'bg-desert-tan text-white',
|
||||
'hover:bg-desert-tan-dark hover:shadow-lg',
|
||||
'active:bg-desert-tan-dark',
|
||||
'disabled:bg-desert-tan-lighter disabled:text-desert-stone-light',
|
||||
|
|
@ -76,7 +76,7 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
|||
|
||||
case 'danger':
|
||||
return clsx(
|
||||
'bg-desert-red text-desert-white',
|
||||
'bg-desert-red text-white',
|
||||
'hover:bg-desert-red-dark hover:shadow-lg',
|
||||
'active:bg-desert-red-dark',
|
||||
'disabled:bg-desert-red-lighter disabled:text-desert-stone-light',
|
||||
|
|
@ -86,7 +86,7 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
|||
|
||||
case 'action':
|
||||
return clsx(
|
||||
'bg-desert-orange text-desert-white',
|
||||
'bg-desert-orange text-white',
|
||||
'hover:bg-desert-orange-light hover:shadow-lg',
|
||||
'active:bg-desert-orange-dark',
|
||||
'disabled:bg-desert-orange-lighter disabled:text-desert-stone-light',
|
||||
|
|
@ -96,7 +96,7 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
|||
|
||||
case 'success':
|
||||
return clsx(
|
||||
'bg-desert-olive text-desert-white',
|
||||
'bg-desert-olive text-white',
|
||||
'hover:bg-desert-olive-dark hover:shadow-lg',
|
||||
'active:bg-desert-olive-dark',
|
||||
'disabled:bg-desert-olive-lighter disabled:text-desert-stone-light',
|
||||
|
|
@ -116,8 +116,8 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
|||
case 'outline':
|
||||
return clsx(
|
||||
'bg-transparent border-2 border-desert-green text-desert-green',
|
||||
'hover:bg-desert-green hover:text-desert-white hover:border-desert-green-dark',
|
||||
'active:bg-desert-green-dark active:border-desert-green-darker',
|
||||
'hover:bg-desert-green hover:text-white hover:border-btn-green-hover',
|
||||
'active:bg-btn-green-hover active:border-btn-green-active',
|
||||
'disabled:border-desert-green-lighter disabled:text-desert-stone-light',
|
||||
baseTransition,
|
||||
baseHover
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ const StyledModal: React.FC<StyledModalProps> = ({
|
|||
>
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 bg-gray-500/75 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in"
|
||||
className="fixed inset-0 bg-black/50 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in"
|
||||
/>
|
||||
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
|
||||
<div
|
||||
|
|
@ -60,14 +60,14 @@ const StyledModal: React.FC<StyledModalProps> = ({
|
|||
<DialogPanel
|
||||
transition
|
||||
className={classNames(
|
||||
'relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all data-[closed]:translate-y-4 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in sm:my-8 sm:p-6 data-[closed]:sm:translate-y-0 data-[closed]:sm:scale-95',
|
||||
'relative transform overflow-hidden rounded-lg bg-surface-primary px-4 pb-4 pt-5 text-left shadow-xl transition-all data-[closed]:translate-y-4 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in sm:my-8 sm:p-6 data-[closed]:sm:translate-y-0 data-[closed]:sm:scale-95',
|
||||
large ? 'sm:max-w-7xl !w-full' : 'sm:max-w-lg'
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{icon && <div className="flex items-center justify-center">{icon}</div>}
|
||||
<div className="mt-3 text-center sm:mt-5">
|
||||
<DialogTitle as="h3" className="text-base font-semibold text-gray-900">
|
||||
<DialogTitle as="h3" className="text-base font-semibold text-text-primary">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<div className="mt-2 !h-fit">{children}</div>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { useMemo, useState } from 'react'
|
||||
import { Dialog, DialogBackdrop, DialogPanel, TransitionChild } from '@headlessui/react'
|
||||
import classNames from '~/lib/classNames'
|
||||
import { IconArrowLeft } from '@tabler/icons-react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import { IconArrowLeft, IconBug } from '@tabler/icons-react'
|
||||
import { Link, usePage } from '@inertiajs/react'
|
||||
import { UsePageProps } from '../../types/system'
|
||||
import { IconMenu2, IconX } from '@tabler/icons-react'
|
||||
import ThemeToggle from '~/components/ThemeToggle'
|
||||
import DebugInfoModal from './DebugInfoModal'
|
||||
|
||||
type SidebarItem = {
|
||||
name: string
|
||||
|
|
@ -21,6 +23,7 @@ interface StyledSidebarProps {
|
|||
|
||||
const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const [debugModalOpen, setDebugModalOpen] = useState(false)
|
||||
const { appVersion } = usePage().props as unknown as UsePageProps
|
||||
|
||||
const currentPath = useMemo(() => {
|
||||
|
|
@ -29,21 +32,29 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
|
|||
}, [])
|
||||
|
||||
const ListItem = (item: SidebarItem) => {
|
||||
return (
|
||||
<li key={item.name}>
|
||||
<a
|
||||
href={item.href}
|
||||
target={item.target}
|
||||
className={classNames(
|
||||
const className = classNames(
|
||||
item.current
|
||||
? 'bg-desert-green text-white'
|
||||
: 'text-black hover:bg-desert-green-light hover:text-white',
|
||||
: 'text-text-primary hover:bg-desert-green-light hover:text-white',
|
||||
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold'
|
||||
)}
|
||||
>
|
||||
)
|
||||
const content = (
|
||||
<>
|
||||
{item.icon && <item.icon aria-hidden="true" className="size-6 shrink-0" />}
|
||||
{item.name}
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<li key={item.name}>
|
||||
{item.target === '_blank' ? (
|
||||
<a href={item.href} target="_blank" rel="noopener noreferrer" className={className}>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<Link href={item.href} className={className}>
|
||||
{content}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
|
@ -52,8 +63,8 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
|
|||
return (
|
||||
<div className="flex grow flex-col gap-y-5 overflow-y-auto bg-desert-sand px-6 ring-1 ring-white/5 pt-4 shadow-md">
|
||||
<div className="flex h-16 shrink-0 items-center">
|
||||
<img src="/project_nomad_logo.png" alt="Project Nomad Logo" className="h-16 w-16" />
|
||||
<h1 className="ml-3 text-xl font-semibold text-black">{title}</h1>
|
||||
<img src="/project_nomad_logo.webp" alt="Project Nomad Logo" className="h-16 w-16" />
|
||||
<h1 className="ml-3 text-xl font-semibold text-text-primary">{title}</h1>
|
||||
</div>
|
||||
<nav className="flex flex-1 flex-col">
|
||||
<ul role="list" className="flex flex-1 flex-col gap-y-7">
|
||||
|
|
@ -63,20 +74,28 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
|
|||
<ListItem key={item.name} {...item} current={currentPath === item.href} />
|
||||
))}
|
||||
<li className="ml-2 mt-4">
|
||||
<a
|
||||
<Link
|
||||
href="/home"
|
||||
className="flex flex-row items-center gap-x-3 text-desert-green text-sm font-semibold"
|
||||
>
|
||||
<IconArrowLeft aria-hidden="true" className="size-6 shrink-0" />
|
||||
Back to Home
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div className="mb-4 text-center text-sm text-gray-600">
|
||||
<div className="mb-4 flex flex-col items-center gap-1 text-sm text-text-secondary text-center">
|
||||
<p>Project N.O.M.A.D. Command Center v{appVersion}</p>
|
||||
<button
|
||||
onClick={() => setDebugModalOpen(true)}
|
||||
className="text-gray-500 hover:text-desert-green inline-flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
<IconBug className="size-3.5" />
|
||||
Debug Info
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -123,6 +142,7 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
|
|||
<div className="hidden xl:fixed xl:inset-y-0 xl:z-50 xl:flex xl:w-72 xl:flex-col">
|
||||
<Sidebar />
|
||||
</div>
|
||||
<DebugInfoModal open={debugModalOpen} onClose={() => setDebugModalOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,19 +74,19 @@ function StyledTable<T extends { [key: string]: any }>({
|
|||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'w-full overflow-x-auto bg-white ring-1 ring-gray-300 sm:mx-0 sm:rounded-lg p-1 shadow-md',
|
||||
'w-full overflow-x-auto bg-surface-primary ring-1 ring-border-default sm:mx-0 sm:rounded-lg p-1 shadow-md',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...containerProps}
|
||||
>
|
||||
<table className="min-w-full overflow-auto" {...restTableProps}>
|
||||
<thead className='border-b border-gray-200 '>
|
||||
<thead className='border-b border-border-subtle '>
|
||||
<tr>
|
||||
{expandable && (
|
||||
<th
|
||||
className={classNames(
|
||||
'whitespace-nowrap text-left font-semibold text-gray-900 w-12',
|
||||
'whitespace-nowrap text-left font-semibold text-text-primary w-12',
|
||||
compact ? `${leftPadding} py-2` : `${leftPadding} py-4 pr-3`
|
||||
)}
|
||||
/>
|
||||
|
|
@ -95,7 +95,7 @@ function StyledTable<T extends { [key: string]: any }>({
|
|||
<th
|
||||
key={index}
|
||||
className={classNames(
|
||||
'whitespace-nowrap text-left font-semibold text-gray-900',
|
||||
'whitespace-nowrap text-left font-semibold text-text-primary',
|
||||
compact ? `${leftPadding} py-2` : `${leftPadding} py-4 pr-3`
|
||||
)}
|
||||
>
|
||||
|
|
@ -121,8 +121,8 @@ function StyledTable<T extends { [key: string]: any }>({
|
|||
'translateY' in record ? 'translateY(' + record.transformY + 'px)' : undefined,
|
||||
}}
|
||||
className={classNames(
|
||||
rowLines ? 'border-b border-gray-200' : '',
|
||||
onRowClick ? `cursor-pointer hover:bg-gray-100 ` : ''
|
||||
rowLines ? 'border-b border-border-subtle' : '',
|
||||
onRowClick ? `cursor-pointer hover:bg-surface-secondary ` : ''
|
||||
)}
|
||||
>
|
||||
{expandable && (
|
||||
|
|
@ -134,7 +134,7 @@ function StyledTable<T extends { [key: string]: any }>({
|
|||
onClick={(e) => toggleRowExpansion(record, recordIdx, e)}
|
||||
>
|
||||
<button
|
||||
className="text-gray-500 hover:text-gray-700 focus:outline-none"
|
||||
className="text-text-muted hover:text-text-primary focus:outline-none"
|
||||
aria-label={isExpanded ? 'Collapse row' : 'Expand row'}
|
||||
>
|
||||
<svg
|
||||
|
|
@ -172,7 +172,7 @@ function StyledTable<T extends { [key: string]: any }>({
|
|||
))}
|
||||
</tr>
|
||||
{expandable && isExpanded && (
|
||||
<tr className="bg-gray-50">
|
||||
<tr className="bg-surface-secondary">
|
||||
<td colSpan={columns.length + 1}>
|
||||
{expandable.expandedRowRender(record, recordIdx)}
|
||||
</td>
|
||||
|
|
@ -183,7 +183,7 @@ function StyledTable<T extends { [key: string]: any }>({
|
|||
})}
|
||||
{!loading && data.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={columns.length + (expandable ? 1 : 0)} className="!text-center py-8 text-gray-500">
|
||||
<td colSpan={columns.length + (expandable ? 1 : 0)} className="!text-center py-8 text-text-muted">
|
||||
{noDataText}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
24
admin/inertia/components/ThemeToggle.tsx
Normal file
24
admin/inertia/components/ThemeToggle.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { IconSun, IconMoon } from '@tabler/icons-react'
|
||||
import { useThemeContext } from '~/providers/ThemeProvider'
|
||||
|
||||
interface ThemeToggleProps {
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export default function ThemeToggle({ compact = false }: ThemeToggleProps) {
|
||||
const { theme, toggleTheme } = useThemeContext()
|
||||
const isDark = theme === 'dark'
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm transition-colors
|
||||
text-desert-stone hover:text-desert-green-darker cursor-pointer"
|
||||
aria-label={isDark ? 'Switch to Day Ops' : 'Switch to Night Ops'}
|
||||
title={isDark ? 'Switch to Day Ops' : 'Switch to Night Ops'}
|
||||
>
|
||||
{isDark ? <IconSun className="size-4" /> : <IconMoon className="size-4" />}
|
||||
{!compact && <span>{isDark ? 'Day Ops' : 'Night Ops'}</span>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { resolveTierResources } from '~/lib/collections'
|
|||
import { formatBytes } from '~/lib/util'
|
||||
import classNames from 'classnames'
|
||||
import DynamicIcon, { DynamicIconName } from './DynamicIcon'
|
||||
import StyledButton from './StyledButton'
|
||||
|
||||
interface TierSelectionModalProps {
|
||||
isOpen: boolean
|
||||
|
|
@ -88,7 +89,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
|||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="w-full max-w-4xl transform overflow-hidden rounded-lg bg-white shadow-xl transition-all">
|
||||
<Dialog.Panel className="w-full max-w-4xl transform overflow-hidden rounded-lg bg-surface-primary shadow-xl transition-all">
|
||||
{/* Header */}
|
||||
<div className="bg-desert-green px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -101,7 +102,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
|||
<Dialog.Title className="text-xl font-semibold text-white">
|
||||
{category.name}
|
||||
</Dialog.Title>
|
||||
<p className="text-sm text-gray-200">{category.description}</p>
|
||||
<p className="text-sm text-text-muted">{category.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -115,7 +116,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
|||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<p className="text-gray-600 mb-6">
|
||||
<p className="text-text-secondary mb-6">
|
||||
Select a tier based on your storage capacity and needs. Higher tiers include all content from lower tiers.
|
||||
</p>
|
||||
|
||||
|
|
@ -138,30 +139,30 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
|||
'border-2 rounded-lg p-5 cursor-pointer transition-all',
|
||||
isSelected
|
||||
? 'border-desert-green bg-desert-green/5 shadow-md'
|
||||
: 'border-gray-200 hover:border-desert-green/50 hover:shadow-sm'
|
||||
: 'border-border-subtle hover:border-desert-green/50 hover:shadow-sm'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
<h3 className="text-lg font-semibold text-text-primary">
|
||||
{tier.name}
|
||||
</h3>
|
||||
{includedTierName && (
|
||||
<span className="text-xs text-gray-500">
|
||||
<span className="text-xs text-text-muted">
|
||||
(includes {includedTierName})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mb-3">{tier.description}</p>
|
||||
<p className="text-text-secondary text-sm mb-3">{tier.description}</p>
|
||||
|
||||
{/* Resources preview - only show this tier's own resources */}
|
||||
<div className="bg-gray-50 rounded p-3">
|
||||
<p className="text-xs text-gray-500 mb-2 font-medium">
|
||||
<div className="bg-surface-secondary rounded p-3">
|
||||
<p className="text-xs text-text-muted mb-2 font-medium">
|
||||
{includedTierName ? (
|
||||
<>
|
||||
{ownResourceCount} additional {ownResourceCount === 1 ? 'resource' : 'resources'}
|
||||
<span className="text-gray-400"> (plus everything in {includedTierName})</span>
|
||||
<span className="text-text-muted"> (plus everything in {includedTierName})</span>
|
||||
</>
|
||||
) : (
|
||||
<>{ownResourceCount} {ownResourceCount === 1 ? 'resource' : 'resources'} included</>
|
||||
|
|
@ -172,8 +173,8 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
|||
<div key={idx} className="flex items-start text-sm">
|
||||
<IconCheck size={14} className="text-desert-green mr-1.5 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<span className="text-gray-700">{resource.title}</span>
|
||||
<span className="text-gray-400 text-xs ml-1">
|
||||
<span className="text-text-primary">{resource.title}</span>
|
||||
<span className="text-text-muted text-xs ml-1">
|
||||
({formatBytes(resource.size_mb * 1024 * 1024, 0)})
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -184,14 +185,14 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
|||
</div>
|
||||
|
||||
<div className="ml-4 text-right flex-shrink-0">
|
||||
<div className="text-lg font-semibold text-gray-900">
|
||||
<div className="text-lg font-semibold text-text-primary">
|
||||
{formatBytes(totalSize, 1)}
|
||||
</div>
|
||||
<div className={classNames(
|
||||
'w-6 h-6 rounded-full border-2 flex items-center justify-center mt-2 ml-auto',
|
||||
isSelected
|
||||
? 'border-desert-green bg-desert-green'
|
||||
: 'border-gray-300'
|
||||
: 'border-border-default'
|
||||
)}>
|
||||
{isSelected && <IconCheck size={16} className="text-white" />}
|
||||
</div>
|
||||
|
|
@ -203,7 +204,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
|||
</div>
|
||||
|
||||
{/* Info note */}
|
||||
<div className="mt-6 flex items-start gap-2 text-sm text-gray-500 bg-blue-50 p-3 rounded">
|
||||
<div className="mt-6 flex items-start gap-2 text-sm text-text-muted bg-blue-50 p-3 rounded">
|
||||
<IconInfoCircle size={18} className="text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<p>
|
||||
You can change your selection at any time. Click Submit to confirm your choice.
|
||||
|
|
@ -212,19 +213,15 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
|||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="bg-gray-50 px-6 py-4 flex justify-end gap-3">
|
||||
<button
|
||||
<div className="bg-surface-secondary px-6 py-4 flex justify-end gap-3">
|
||||
<StyledButton
|
||||
variant='primary'
|
||||
size='lg'
|
||||
onClick={handleSubmit}
|
||||
disabled={!localSelectedSlug}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-md font-medium transition-colors',
|
||||
localSelectedSlug
|
||||
? 'bg-desert-green text-white hover:bg-desert-green/90'
|
||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</StyledButton>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
|
|
|
|||
|
|
@ -60,12 +60,12 @@ export default function UpdateServiceModal({
|
|||
icon={<IconArrowUp className="h-12 w-12 text-desert-green" />}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-700">
|
||||
<p className="text-text-primary">
|
||||
Update <strong>{record.friendly_name || record.service_name}</strong> from{' '}
|
||||
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-sm">{currentTag}</code> to{' '}
|
||||
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-sm">{selectedVersion}</code>?
|
||||
<code className="bg-surface-secondary px-1.5 py-0.5 rounded text-sm">{currentTag}</code> to{' '}
|
||||
<code className="bg-surface-secondary px-1.5 py-0.5 rounded text-sm">{selectedVersion}</code>?
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-sm text-text-muted">
|
||||
Your data and configuration will be preserved during the update.
|
||||
{versions.find((v) => v.tag === selectedVersion)?.releaseUrl && (
|
||||
<>
|
||||
|
|
@ -95,14 +95,14 @@ export default function UpdateServiceModal({
|
|||
<>
|
||||
<div className="mt-3 max-h-48 overflow-y-auto border rounded-lg divide-y">
|
||||
{loadingVersions ? (
|
||||
<div className="p-4 text-center text-gray-500 text-sm">Loading versions...</div>
|
||||
<div className="p-4 text-center text-text-muted text-sm">Loading versions...</div>
|
||||
) : versions.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500 text-sm">No other versions available</div>
|
||||
<div className="p-4 text-center text-text-muted text-sm">No other versions available</div>
|
||||
) : (
|
||||
versions.map((v) => (
|
||||
<label
|
||||
key={v.tag}
|
||||
className="flex items-center gap-3 px-4 py-2.5 hover:bg-gray-50 cursor-pointer"
|
||||
className="flex items-center gap-3 px-4 py-2.5 hover:bg-surface-secondary cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
|
|
@ -112,7 +112,7 @@ export default function UpdateServiceModal({
|
|||
onChange={() => setSelectedVersion(v.tag)}
|
||||
className="text-desert-green focus:ring-desert-green"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-900">{v.tag}</span>
|
||||
<span className="text-sm font-medium text-text-primary">{v.tag}</span>
|
||||
{v.isLatest && (
|
||||
<span className="text-xs bg-desert-green/10 text-desert-green px-2 py-0.5 rounded-full">
|
||||
Latest
|
||||
|
|
@ -133,7 +133,7 @@ export default function UpdateServiceModal({
|
|||
))
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
<p className="mt-2 text-sm text-text-muted">
|
||||
It's not recommended to upgrade to a new major version (e.g. 1.8.2 → 2.0.0) unless you have verified compatibility with your current configuration. Always review the release notes and test in a staging environment if possible.
|
||||
</p>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { formatBytes } from '~/lib/util'
|
||||
import { WikipediaOption, WikipediaCurrentSelection } from '../../types/downloads'
|
||||
import classNames from 'classnames'
|
||||
import { IconCheck, IconDownload, IconWorld } from '@tabler/icons-react'
|
||||
import { IconCheck, IconDownload, IconWorld, IconAlertTriangle } from '@tabler/icons-react'
|
||||
import StyledButton from './StyledButton'
|
||||
import LoadingSpinner from './LoadingSpinner'
|
||||
|
||||
|
|
@ -29,32 +29,45 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
|
|||
// Determine which option to highlight
|
||||
const highlightedOptionId = selectedOptionId ?? currentSelection?.optionId ?? null
|
||||
|
||||
// Check if current selection is downloading
|
||||
// Check if current selection is downloading or failed
|
||||
const isDownloading = currentSelection?.status === 'downloading'
|
||||
const isFailed = currentSelection?.status === 'failed'
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* Header with Wikipedia branding */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-full bg-white flex items-center justify-center shadow-sm">
|
||||
<IconWorld className="w-6 h-6 text-gray-700" />
|
||||
<IconWorld className="w-6 h-6 text-text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-gray-900">Wikipedia</h3>
|
||||
<p className="text-sm text-gray-500">Select your preferred Wikipedia package</p>
|
||||
<h3 className="text-xl font-semibold text-text-primary">Wikipedia</h3>
|
||||
<p className="text-sm text-text-muted">Select your preferred Wikipedia package</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Downloading status message */}
|
||||
{isDownloading && (
|
||||
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-center gap-2">
|
||||
<LoadingSpinner fullscreen={false} iconOnly className="size-5" />
|
||||
<LoadingSpinner fullscreen={false} iconOnly className="size-4" />
|
||||
<span className="text-sm text-blue-700">
|
||||
Downloading Wikipedia... This may take a while for larger packages.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Failed status message */}
|
||||
{isFailed && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconAlertTriangle className="w-5 h-5 text-red-600 flex-shrink-0" />
|
||||
<span className="text-sm text-red-700">
|
||||
Wikipedia download failed. Select a package and try again.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{options.map((option) => {
|
||||
|
|
@ -63,6 +76,8 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
|
|||
currentSelection?.optionId === option.id && currentSelection?.status === 'installed'
|
||||
const isCurrentDownloading =
|
||||
currentSelection?.optionId === option.id && currentSelection?.status === 'downloading'
|
||||
const isCurrentFailed =
|
||||
currentSelection?.optionId === option.id && currentSelection?.status === 'failed'
|
||||
const isPending = selectedOptionId === option.id && selectedOptionId !== currentSelection?.optionId
|
||||
|
||||
return (
|
||||
|
|
@ -78,7 +93,7 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
|
|||
? 'border-desert-green bg-desert-green/10'
|
||||
: isSelected
|
||||
? 'border-lime-500 bg-lime-50'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
: 'border-border-subtle bg-surface-primary hover:border-border-default'
|
||||
)}
|
||||
>
|
||||
{/* Status badges */}
|
||||
|
|
@ -100,12 +115,18 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
|
|||
Downloading
|
||||
</span>
|
||||
)}
|
||||
{isCurrentFailed && (
|
||||
<span className="text-xs bg-red-500 text-white px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<IconAlertTriangle size={12} />
|
||||
Failed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Option content */}
|
||||
<div className="pr-16 flex flex-col h-full">
|
||||
<h4 className="text-lg font-semibold text-gray-900 mb-1">{option.name}</h4>
|
||||
<p className="text-sm text-gray-600 mb-3 flex-grow">{option.description}</p>
|
||||
<h4 className="text-lg font-semibold text-text-primary mb-1">{option.name}</h4>
|
||||
<p className="text-sm text-text-secondary mb-3 flex-grow">{option.description}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Radio indicator */}
|
||||
<div
|
||||
|
|
@ -115,7 +136,7 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
|
|||
? isInstalled
|
||||
? 'border-desert-green bg-desert-green'
|
||||
: 'border-lime-500 bg-lime-500'
|
||||
: 'border-gray-300'
|
||||
: 'border-border-default'
|
||||
)}
|
||||
>
|
||||
{isSelected && <IconCheck size={12} className="text-white" />}
|
||||
|
|
@ -123,7 +144,7 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
|
|||
<span
|
||||
className={classNames(
|
||||
'text-sm font-medium px-2 py-1 rounded',
|
||||
option.size_mb === 0 ? 'bg-gray-100 text-gray-500' : 'bg-gray-100 text-gray-700'
|
||||
option.size_mb === 0 ? 'bg-surface-secondary text-text-muted' : 'bg-surface-secondary text-text-secondary'
|
||||
)}
|
||||
>
|
||||
{option.size_mb === 0 ? 'No download' : formatBytes(option.size_mb * 1024 * 1024, 1)}
|
||||
|
|
@ -136,7 +157,7 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
|
|||
</div>
|
||||
|
||||
{/* Submit button for Content Explorer mode */}
|
||||
{showSubmitButton && selectedOptionId && selectedOptionId !== currentSelection?.optionId && (
|
||||
{showSubmitButton && selectedOptionId && (selectedOptionId !== currentSelection?.optionId || isFailed) && (
|
||||
<div className="mt-4 flex justify-end">
|
||||
<StyledButton
|
||||
variant="primary"
|
||||
|
|
|
|||
|
|
@ -85,19 +85,19 @@ export default function ChatInterface({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 bg-white shadow-sm">
|
||||
<div className="flex-1 flex flex-col min-h-0 bg-surface-primary shadow-sm">
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
||||
{messages.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center max-w-md">
|
||||
<IconWand className="h-16 w-16 text-desert-green mx-auto mb-4 opacity-50" />
|
||||
<h3 className="text-lg font-medium text-gray-700 mb-2">Start a conversation</h3>
|
||||
<p className="text-gray-500 text-sm">
|
||||
<h3 className="text-lg font-medium text-text-primary mb-2">Start a conversation</h3>
|
||||
<p className="text-text-muted text-sm">
|
||||
Interact with your installed language models directly in the Command Center.
|
||||
</p>
|
||||
{chatSuggestionsEnabled && chatSuggestions && chatSuggestions.length > 0 && !chatSuggestionsLoading && (
|
||||
<div className="mt-8">
|
||||
<h4 className="text-sm font-medium text-gray-600 mb-2">Suggestions:</h4>
|
||||
<h4 className="text-sm font-medium text-text-secondary mb-2">Suggestions:</h4>
|
||||
<div className="flex flex-col gap-2">
|
||||
{chatSuggestions.map((suggestion, index) => (
|
||||
<button
|
||||
|
|
@ -109,7 +109,7 @@ export default function ChatInterface({
|
|||
textareaRef.current?.focus()
|
||||
}, 0)
|
||||
}}
|
||||
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm text-gray-700 transition-colors"
|
||||
className="px-4 py-2 bg-surface-secondary hover:bg-surface-secondary rounded-lg text-sm text-text-primary transition-colors"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
|
|
@ -120,7 +120,7 @@ export default function ChatInterface({
|
|||
{/* Display bouncing dots while loading suggestions */}
|
||||
{chatSuggestionsEnabled && chatSuggestionsLoading && <BouncingDots text="Thinking" containerClassName="mt-8" />}
|
||||
{!chatSuggestionsEnabled && (
|
||||
<div className="mt-8 text-sm text-gray-500">
|
||||
<div className="mt-8 text-sm text-text-muted">
|
||||
Need some inspiration? Enable chat suggestions in settings to get started with example prompts.
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -144,7 +144,7 @@ export default function ChatInterface({
|
|||
{isLoading && (
|
||||
<div className="flex gap-4 justify-start">
|
||||
<ChatAssistantAvatar />
|
||||
<div className="max-w-[70%] rounded-lg px-4 py-3 bg-gray-100 text-gray-800">
|
||||
<div className="max-w-[70%] rounded-lg px-4 py-3 bg-surface-secondary text-text-primary">
|
||||
<BouncingDots text="Thinking" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -154,7 +154,7 @@ export default function ChatInterface({
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-gray-200 bg-white px-6 py-4 flex-shrink-0 min-h-[90px]">
|
||||
<div className="border-t border-border-subtle bg-surface-primary px-6 py-4 flex-shrink-0 min-h-[90px]">
|
||||
<form onSubmit={handleSubmit} className="flex gap-3 items-end">
|
||||
<div className="flex-1 relative">
|
||||
<textarea
|
||||
|
|
@ -163,7 +163,7 @@ export default function ChatInterface({
|
|||
onChange={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`Type your message to ${aiAssistantName}... (Shift+Enter for new line)`}
|
||||
className="w-full resize-none rounded-lg border border-gray-300 px-4 py-3 pr-12 focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent disabled:bg-gray-50 disabled:text-gray-500"
|
||||
className="w-full resize-none rounded-lg border border-border-default px-4 py-3 pr-12 focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent disabled:bg-surface-secondary disabled:text-text-muted"
|
||||
rows={1}
|
||||
disabled={isLoading}
|
||||
style={{ maxHeight: '200px' }}
|
||||
|
|
@ -175,7 +175,7 @@ export default function ChatInterface({
|
|||
className={classNames(
|
||||
'p-3 rounded-lg transition-all duration-200 flex-shrink-0 mb-2',
|
||||
!input.trim() || isLoading
|
||||
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
? 'bg-border-default text-text-muted cursor-not-allowed'
|
||||
: 'bg-desert-green text-white hover:bg-desert-green/90 hover:scale-105'
|
||||
)}
|
||||
>
|
||||
|
|
@ -187,7 +187,7 @@ export default function ChatInterface({
|
|||
</button>
|
||||
</form>
|
||||
{!rewriteModelAvailable && (
|
||||
<div className="text-sm text-gray-500 mt-2">
|
||||
<div className="text-sm text-text-muted mt-2">
|
||||
The {DEFAULT_QUERY_REWRITE_MODEL} model is not installed. Consider{' '}
|
||||
<button
|
||||
onClick={() => setDownloadDialogOpen(true)}
|
||||
|
|
@ -210,10 +210,10 @@ export default function ChatInterface({
|
|||
onCancel={() => setDownloadDialogOpen(false)}
|
||||
onClose={() => setDownloadDialogOpen(false)}
|
||||
>
|
||||
<p className="text-gray-700">
|
||||
<p className="text-text-primary">
|
||||
This will dispatch a background download job for{' '}
|
||||
<span className="font-mono font-medium">{DEFAULT_QUERY_REWRITE_MODEL}</span> and may take some time to complete. The model
|
||||
will be used to rewrite queries for improved RAG retrieval performance.
|
||||
will be used to rewrite queries for improved RAG retrieval performance. Note that download is only supported when using Ollama. If using an OpenAI API interface, please download the model with that software.
|
||||
</p>
|
||||
</StyledModal>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export default function ChatMessageBubble({ message }: ChatMessageBubbleProps) {
|
|||
<div
|
||||
className={classNames(
|
||||
'max-w-[70%] rounded-lg px-4 py-3',
|
||||
message.role === 'user' ? 'bg-desert-green text-white' : 'bg-gray-100 text-gray-800'
|
||||
message.role === 'user' ? 'bg-desert-green text-white' : 'bg-surface-secondary text-text-primary'
|
||||
)}
|
||||
>
|
||||
{message.isThinking && message.thinking && (
|
||||
|
|
@ -27,13 +27,13 @@ export default function ChatMessageBubble({ message }: ChatMessageBubbleProps) {
|
|||
</div>
|
||||
)}
|
||||
{!message.isThinking && message.thinking && (
|
||||
<details className="mb-3 rounded border border-gray-200 bg-gray-50 text-xs">
|
||||
<summary className="cursor-pointer px-3 py-2 font-medium text-gray-500 hover:text-gray-700 select-none">
|
||||
<details className="mb-3 rounded border border-border-subtle bg-surface-secondary text-xs">
|
||||
<summary className="cursor-pointer px-3 py-2 font-medium text-text-muted hover:text-text-primary select-none">
|
||||
{message.thinkingDuration !== undefined
|
||||
? `Thought for ${message.thinkingDuration}s`
|
||||
: 'Reasoning'}
|
||||
</summary>
|
||||
<div className="px-3 pb-3 prose prose-xs max-w-none text-gray-600 max-h-48 overflow-y-auto border-t border-gray-200 pt-2">
|
||||
<div className="px-3 pb-3 prose prose-xs max-w-none text-text-secondary max-h-48 overflow-y-auto border-t border-border-subtle pt-2">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{message.thinking}</ReactMarkdown>
|
||||
</div>
|
||||
</details>
|
||||
|
|
@ -77,7 +77,7 @@ export default function ChatMessageBubble({ message }: ChatMessageBubbleProps) {
|
|||
h2: ({ children }) => <h2 className="text-lg font-bold mb-2">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="text-base font-bold mb-2">{children}</h3>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-gray-400 pl-4 italic my-2">
|
||||
<blockquote className="border-l-4 border-border-default pl-4 italic my-2">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
|
|
@ -105,7 +105,7 @@ export default function ChatMessageBubble({ message }: ChatMessageBubbleProps) {
|
|||
<div
|
||||
className={classNames(
|
||||
'text-xs mt-2',
|
||||
message.role === 'user' ? 'text-white/70' : 'text-gray-500'
|
||||
message.role === 'user' ? 'text-white/70' : 'text-text-muted'
|
||||
)}
|
||||
>
|
||||
{message.timestamp.toLocaleTimeString([], {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export default function ChatModal({ open, onClose }: ChatModalProps) {
|
|||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<DialogPanel
|
||||
transition
|
||||
className="relative bg-white rounded-xl shadow-2xl w-full max-w-7xl h-[85vh] flex overflow-hidden transition-all data-[closed]:scale-95 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in"
|
||||
className="relative bg-surface-primary rounded-xl shadow-2xl w-full max-w-7xl h-[85vh] flex overflow-hidden transition-all data-[closed]:scale-95 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in"
|
||||
>
|
||||
<Chat enabled={open} isInModal onClose={onClose} suggestionsEnabled={parseBoolean(settings.data?.value)} />
|
||||
</DialogPanel>
|
||||
|
|
|
|||
|
|
@ -39,8 +39,8 @@ export default function ChatSidebar({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="w-64 bg-gray-50 border-r border-gray-200 flex flex-col h-full">
|
||||
<div className="p-4 border-b border-gray-200 h-[75px] flex items-center justify-center">
|
||||
<div className="w-64 bg-surface-secondary border-r border-border-subtle flex flex-col h-full">
|
||||
<div className="p-4 border-b border-border-subtle h-[75px] flex items-center justify-center">
|
||||
<StyledButton onClick={onNewChat} icon="IconPlus" variant="primary" fullWidth>
|
||||
New Chat
|
||||
</StyledButton>
|
||||
|
|
@ -48,7 +48,7 @@ export default function ChatSidebar({
|
|||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500 text-sm">No previous chats</div>
|
||||
<div className="p-4 text-center text-text-muted text-sm">No previous chats</div>
|
||||
) : (
|
||||
<div className="p-2 space-y-1">
|
||||
{sessions.map((session) => (
|
||||
|
|
@ -59,14 +59,14 @@ export default function ChatSidebar({
|
|||
'w-full text-left px-3 py-2 rounded-lg transition-colors group',
|
||||
activeSessionId === session.id
|
||||
? 'bg-desert-green text-white'
|
||||
: 'hover:bg-gray-200 text-gray-700'
|
||||
: 'hover:bg-surface-secondary text-text-primary'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<IconMessage
|
||||
className={classNames(
|
||||
'h-5 w-5 mt-0.5 shrink-0',
|
||||
activeSessionId === session.id ? 'text-white' : 'text-gray-400'
|
||||
activeSessionId === session.id ? 'text-white' : 'text-text-muted'
|
||||
)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
|
|
@ -75,7 +75,7 @@ export default function ChatSidebar({
|
|||
<div
|
||||
className={classNames(
|
||||
'text-xs truncate mt-0.5',
|
||||
activeSessionId === session.id ? 'text-white/80' : 'text-gray-500'
|
||||
activeSessionId === session.id ? 'text-white/80' : 'text-text-muted'
|
||||
)}
|
||||
>
|
||||
{session.lastMessage}
|
||||
|
|
@ -89,7 +89,7 @@ export default function ChatSidebar({
|
|||
)}
|
||||
</div>
|
||||
<div className="p-4 flex flex-col items-center justify-center gap-y-2">
|
||||
<img src="/project_nomad_logo.png" alt="Project Nomad Logo" className="h-28 w-28 mb-6" />
|
||||
<img src="/project_nomad_logo.webp" alt="Project Nomad Logo" className="h-28 w-28 mb-6" />
|
||||
<StyledButton
|
||||
onClick={() => {
|
||||
if (isInModal) {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ function sourceToDisplayName(source: string): string {
|
|||
export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", onClose }: KnowledgeBaseModalProps) {
|
||||
const { addNotification } = useNotifications()
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [confirmDeleteSource, setConfirmDeleteSource] = useState<string | null>(null)
|
||||
const fileUploaderRef = useRef<React.ComponentRef<typeof FileUploader>>(null)
|
||||
const { openModal, closeModal } = useModals()
|
||||
|
|
@ -37,22 +38,6 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => api.uploadDocument(file),
|
||||
onSuccess: (data) => {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: data?.message || 'Document uploaded and queued for processing',
|
||||
})
|
||||
setFiles([])
|
||||
if (fileUploaderRef.current) {
|
||||
fileUploaderRef.current.clear()
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: error?.message || 'Failed to upload document',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
|
|
@ -68,6 +53,17 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
},
|
||||
})
|
||||
|
||||
const cleanupFailedMutation = useMutation({
|
||||
mutationFn: () => api.cleanupFailedEmbedJobs(),
|
||||
onSuccess: (data) => {
|
||||
addNotification({ type: 'success', message: data?.message || 'Failed jobs cleaned up.' })
|
||||
queryClient.invalidateQueries({ queryKey: ['failedEmbedJobs'] })
|
||||
},
|
||||
onError: (error: any) => {
|
||||
addNotification({ type: 'error', message: error?.message || 'Failed to clean up jobs.' })
|
||||
},
|
||||
})
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: () => api.syncRAGStorage(),
|
||||
onSuccess: (data) => {
|
||||
|
|
@ -84,9 +80,34 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
},
|
||||
})
|
||||
|
||||
const handleUpload = () => {
|
||||
if (files.length > 0) {
|
||||
uploadMutation.mutate(files[0])
|
||||
const handleUpload = async () => {
|
||||
if (files.length === 0) return
|
||||
setIsUploading(true)
|
||||
let successCount = 0
|
||||
const failedNames: string[] = []
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
await uploadMutation.mutateAsync(file)
|
||||
successCount++
|
||||
} catch (error: any) {
|
||||
failedNames.push(file.name)
|
||||
}
|
||||
}
|
||||
|
||||
setIsUploading(false)
|
||||
setFiles([])
|
||||
fileUploaderRef.current?.clear()
|
||||
queryClient.invalidateQueries({ queryKey: ['embed-jobs'] })
|
||||
|
||||
if (successCount > 0) {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: `${successCount} file${successCount > 1 ? 's' : ''} queued for processing.`,
|
||||
})
|
||||
}
|
||||
for (const name of failedNames) {
|
||||
addNotification({ type: 'error', message: `Failed to upload: ${name}` })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -106,7 +127,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
cancelText='Cancel'
|
||||
confirmVariant='primary'
|
||||
>
|
||||
<p className='text-gray-700'>
|
||||
<p className='text-text-primary'>
|
||||
This will scan the NOMAD's storage directories for any new files and queue them for processing. This is useful if you've manually added files to the storage or want to ensure everything is up to date.
|
||||
This may cause a temporary increase in resource usage if new files are found and being processed. Are you sure you want to proceed?
|
||||
</p>
|
||||
|
|
@ -117,23 +138,23 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/30 backdrop-blur-sm transition-opacity">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 shrink-0">
|
||||
<h2 className="text-2xl font-semibold text-gray-800">Knowledge Base</h2>
|
||||
<div className="bg-surface-primary rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between p-6 border-b border-border-subtle shrink-0">
|
||||
<h2 className="text-2xl font-semibold text-text-primary">Knowledge Base</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
className="p-2 hover:bg-surface-secondary rounded-lg transition-colors"
|
||||
>
|
||||
<IconX className="h-6 w-6 text-gray-500" />
|
||||
<IconX className="h-6 w-6 text-text-muted" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 p-6">
|
||||
<div className="bg-white rounded-lg border shadow-md overflow-hidden">
|
||||
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden">
|
||||
<div className="p-6">
|
||||
<FileUploader
|
||||
ref={fileUploaderRef}
|
||||
minFiles={1}
|
||||
maxFiles={1}
|
||||
maxFiles={5}
|
||||
onUpload={(uploadedFiles) => {
|
||||
setFiles(Array.from(uploadedFiles))
|
||||
}}
|
||||
|
|
@ -144,14 +165,14 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
size="lg"
|
||||
icon="IconUpload"
|
||||
onClick={handleUpload}
|
||||
disabled={files.length === 0 || uploadMutation.isPending}
|
||||
loading={uploadMutation.isPending}
|
||||
disabled={files.length === 0 || isUploading}
|
||||
loading={isUploading}
|
||||
>
|
||||
Upload
|
||||
</StyledButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t bg-white p-6">
|
||||
<div className="border-t bg-surface-primary p-6">
|
||||
<h3 className="text-lg font-semibold text-desert-green mb-4">
|
||||
Why upload documents to your Knowledge Base?
|
||||
</h3>
|
||||
|
|
@ -207,7 +228,20 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
</div>
|
||||
</div>
|
||||
<div className="my-8">
|
||||
<ActiveEmbedJobs withHeader={true} />
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<StyledSectionHeader title="Processing Queue" className="!mb-0" />
|
||||
<StyledButton
|
||||
variant="danger"
|
||||
size="md"
|
||||
icon="IconTrash"
|
||||
onClick={() => cleanupFailedMutation.mutate()}
|
||||
loading={cleanupFailedMutation.isPending}
|
||||
disabled={cleanupFailedMutation.isPending}
|
||||
>
|
||||
Clean Up Failed
|
||||
</StyledButton>
|
||||
</div>
|
||||
<ActiveEmbedJobs withHeader={false} />
|
||||
</div>
|
||||
|
||||
<div className="my-12">
|
||||
|
|
@ -218,8 +252,8 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
size="md"
|
||||
icon='IconRefresh'
|
||||
onClick={handleConfirmSync}
|
||||
disabled={syncMutation.isPending || uploadMutation.isPending}
|
||||
loading={syncMutation.isPending || uploadMutation.isPending}
|
||||
disabled={syncMutation.isPending || isUploading}
|
||||
loading={syncMutation.isPending || isUploading}
|
||||
>
|
||||
Sync Storage
|
||||
</StyledButton>
|
||||
|
|
@ -232,7 +266,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
accessor: 'source',
|
||||
title: 'File Name',
|
||||
render(record) {
|
||||
return <span className="text-gray-700">{sourceToDisplayName(record.source)}</span>
|
||||
return <span className="text-text-primary">{sourceToDisplayName(record.source)}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -244,7 +278,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
if (isConfirming) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<span className="text-sm text-gray-600">Remove from knowledge base?</span>
|
||||
<span className="text-sm text-text-secondary">Remove from knowledge base?</span>
|
||||
<StyledButton
|
||||
variant='danger'
|
||||
size='sm'
|
||||
|
|
|
|||
|
|
@ -53,6 +53,14 @@ export default function Chat({
|
|||
const activeSession = sessions.find((s) => s.id === activeSessionId)
|
||||
|
||||
const { data: lastModelSetting } = useSystemSetting({ key: 'chat.lastModel', enabled })
|
||||
const { data: remoteOllamaUrlSetting } = useSystemSetting({ key: 'ai.remoteOllamaUrl', enabled })
|
||||
|
||||
const { data: remoteStatus } = useQuery({
|
||||
queryKey: ['remoteOllamaStatus'],
|
||||
queryFn: () => api.getRemoteOllamaStatus(),
|
||||
enabled: enabled && !!remoteOllamaUrlSetting?.value,
|
||||
refetchInterval: 15000,
|
||||
})
|
||||
|
||||
const { data: installedModels = [], isLoading: isLoadingModels } = useQuery({
|
||||
queryKey: ['installedModels'],
|
||||
|
|
@ -159,7 +167,7 @@ export default function Chat({
|
|||
cancelText="Cancel"
|
||||
confirmVariant="danger"
|
||||
>
|
||||
<p className="text-gray-700">
|
||||
<p className="text-text-primary">
|
||||
Are you sure you want to delete all chat sessions? This action cannot be undone and all
|
||||
conversations will be permanently deleted.
|
||||
</p>
|
||||
|
|
@ -345,7 +353,7 @@ export default function Chat({
|
|||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'flex border border-gray-200 overflow-hidden shadow-sm w-full',
|
||||
'flex border border-border-subtle overflow-hidden shadow-sm w-full',
|
||||
isInModal ? 'h-full rounded-lg' : 'h-screen'
|
||||
)}
|
||||
>
|
||||
|
|
@ -358,17 +366,29 @@ export default function Chat({
|
|||
isInModal={isInModal}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="px-6 py-3 border-b border-gray-200 bg-gray-50 flex items-center justify-between h-[75px] flex-shrink-0">
|
||||
<h2 className="text-lg font-semibold text-gray-800">
|
||||
<div className="px-6 py-3 border-b border-border-subtle bg-surface-secondary flex items-center justify-between h-[75px] flex-shrink-0">
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
{activeSession?.title || 'New Chat'}
|
||||
</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
{remoteOllamaUrlSetting?.value && (
|
||||
<span
|
||||
className={classNames(
|
||||
'text-xs rounded px-2 py-1 font-medium',
|
||||
remoteStatus?.connected === false
|
||||
? 'text-red-700 bg-red-50 border border-red-200'
|
||||
: 'text-green-700 bg-green-50 border border-green-200'
|
||||
)}
|
||||
>
|
||||
{remoteStatus?.connected === false ? 'Remote Disconnected' : 'Remote Connected'}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="model-select" className="text-sm text-gray-600">
|
||||
<label htmlFor="model-select" className="text-sm text-text-secondary">
|
||||
Model:
|
||||
</label>
|
||||
{isLoadingModels ? (
|
||||
<div className="text-sm text-gray-500">Loading models...</div>
|
||||
<div className="text-sm text-text-muted">Loading models...</div>
|
||||
) : installedModels.length === 0 ? (
|
||||
<div className="text-sm text-red-600">No models installed</div>
|
||||
) : (
|
||||
|
|
@ -376,11 +396,11 @@ export default function Chat({
|
|||
id="model-select"
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent bg-white"
|
||||
className="px-3 py-1.5 border border-border-default rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent bg-surface-primary"
|
||||
>
|
||||
{installedModels.map((model) => (
|
||||
<option key={model.name} value={model.name}>
|
||||
{model.name} ({formatBytes(model.size)})
|
||||
{model.name}{model.size > 0 ? ` (${formatBytes(model.size)})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
@ -393,9 +413,9 @@ export default function Chat({
|
|||
onClose()
|
||||
}
|
||||
}}
|
||||
className="rounded-lg hover:bg-gray-100 transition-colors"
|
||||
className="rounded-lg hover:bg-surface-secondary transition-colors"
|
||||
>
|
||||
<IconX className="h-6 w-6 text-gray-500" />
|
||||
<IconX className="h-6 w-6 text-text-muted" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ const FileUploader = forwardRef<FileUploaderRef, FileUploaderProps>((props, ref)
|
|||
const {
|
||||
minFiles = 0,
|
||||
maxFiles = 1,
|
||||
maxFileSize = 10485760, // default to 10MB
|
||||
maxFileSize = 104857600, // default to 100MB
|
||||
fileTypes,
|
||||
disabled = false,
|
||||
onUpload,
|
||||
|
|
|
|||
|
|
@ -31,11 +31,11 @@ const Input: React.FC<InputProps> = ({
|
|||
<div className={classNames(className)}>
|
||||
<label
|
||||
htmlFor={name}
|
||||
className={classNames("block text-base/6 font-medium text-gray-700", labelClassName)}
|
||||
className={classNames("block text-base/6 font-medium text-text-primary", labelClassName)}
|
||||
>
|
||||
{label}{required ? "*" : ""}
|
||||
</label>
|
||||
{helpText && <p className="mt-1 text-sm text-gray-500">{helpText}</p>}
|
||||
{helpText && <p className="mt-1 text-sm text-text-muted">{helpText}</p>}
|
||||
<div className={classNames("mt-1.5", containerClassName)}>
|
||||
<div className="relative">
|
||||
{leftIcon && (
|
||||
|
|
@ -49,7 +49,7 @@ const Input: React.FC<InputProps> = ({
|
|||
placeholder={props.placeholder || label}
|
||||
className={classNames(
|
||||
inputClassName,
|
||||
"block w-full rounded-md bg-white px-3 py-2 text-base text-gray-900 border border-gray-400 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-primary sm:text-sm/6",
|
||||
"block w-full rounded-md bg-surface-primary px-3 py-2 text-base text-text-primary border border-border-default placeholder:text-text-muted focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-primary sm:text-sm/6",
|
||||
leftIcon ? "pl-10" : "pl-3",
|
||||
error ? "!border-red-500 focus:outline-red-500 !bg-red-100" : ""
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -26,12 +26,12 @@ export default function Switch({
|
|||
{label && (
|
||||
<label
|
||||
htmlFor={switchId}
|
||||
className="text-base font-medium text-gray-900 cursor-pointer"
|
||||
className="text-base font-medium text-text-primary cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
{description && <p className="text-sm text-gray-500 mt-1">{description}</p>}
|
||||
{description && <p className="text-sm text-text-muted mt-1">{description}</p>}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center ml-4">
|
||||
|
|
@ -45,7 +45,7 @@ export default function Switch({
|
|||
className={clsx(
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent',
|
||||
'transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-desert-green focus:ring-offset-2',
|
||||
checked ? 'bg-desert-green' : 'bg-gray-200',
|
||||
checked ? 'bg-desert-green' : 'bg-border-default',
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : ''
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ interface BackToHomeHeaderProps {
|
|||
|
||||
export default function BackToHomeHeader({ className, children }: BackToHomeHeaderProps) {
|
||||
return (
|
||||
<div className={classNames('flex border-b border-gray-900/10 p-4', className)}>
|
||||
<div className={classNames('flex border-b border-border-subtle p-4', className)}>
|
||||
<div className="justify-self-start">
|
||||
<Link href="/home" className="flex items-center">
|
||||
<IconArrowLeft className="mr-2" size={24} />
|
||||
<p className="text-lg text-gray-600">Back to Home</p>
|
||||
<p className="text-lg text-text-secondary">Back to Home</p>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex-grow flex flex-col justify-center">{children}</div>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,41 @@
|
|||
import Map, { FullscreenControl, NavigationControl, MapProvider } from 'react-map-gl/maplibre'
|
||||
import Map, {
|
||||
FullscreenControl,
|
||||
NavigationControl,
|
||||
ScaleControl,
|
||||
Marker,
|
||||
Popup,
|
||||
MapProvider,
|
||||
} from 'react-map-gl/maplibre'
|
||||
import type { MapRef, MapLayerMouseEvent } from 'react-map-gl/maplibre'
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { Protocol } from 'pmtiles'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
|
||||
type ScaleUnit = 'imperial' | 'metric'
|
||||
import { useMapMarkers, PIN_COLORS } from '~/hooks/useMapMarkers'
|
||||
import type { PinColorId } from '~/hooks/useMapMarkers'
|
||||
import MarkerPin from './MarkerPin'
|
||||
import MarkerPanel from './MarkerPanel'
|
||||
|
||||
export default function MapComponent() {
|
||||
const mapRef = useRef<MapRef>(null)
|
||||
const { markers, addMarker, deleteMarker } = useMapMarkers()
|
||||
const [placingMarker, setPlacingMarker] = useState<{ lng: number; lat: number } | null>(null)
|
||||
const [markerName, setMarkerName] = useState('')
|
||||
const [markerColor, setMarkerColor] = useState<PinColorId>('orange')
|
||||
const [selectedMarkerId, setSelectedMarkerId] = useState<number | null>(null)
|
||||
const [scaleUnit, setScaleUnit] = useState<ScaleUnit>(
|
||||
() => (localStorage.getItem('nomad:map-scale-unit') as ScaleUnit) || 'metric'
|
||||
)
|
||||
|
||||
const toggleScaleUnit = useCallback(() => {
|
||||
setScaleUnit((prev) => {
|
||||
const next = prev === 'metric' ? 'imperial' : 'metric'
|
||||
localStorage.setItem('nomad:map-scale-unit', next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Add the PMTiles protocol to maplibre-gl
|
||||
useEffect(() => {
|
||||
|
|
@ -15,25 +46,199 @@ export default function MapComponent() {
|
|||
}
|
||||
}, [])
|
||||
|
||||
const handleMapClick = useCallback((e: MapLayerMouseEvent) => {
|
||||
setPlacingMarker({ lng: e.lngLat.lng, lat: e.lngLat.lat })
|
||||
setMarkerName('')
|
||||
setMarkerColor('orange')
|
||||
setSelectedMarkerId(null)
|
||||
}, [])
|
||||
|
||||
const handleSaveMarker = useCallback(() => {
|
||||
if (placingMarker && markerName.trim()) {
|
||||
addMarker(markerName.trim(), placingMarker.lng, placingMarker.lat, markerColor)
|
||||
setPlacingMarker(null)
|
||||
setMarkerName('')
|
||||
setMarkerColor('orange')
|
||||
}
|
||||
}, [placingMarker, markerName, markerColor, addMarker])
|
||||
|
||||
const handleFlyTo = useCallback((longitude: number, latitude: number) => {
|
||||
mapRef.current?.flyTo({ center: [longitude, latitude], zoom: 12, duration: 1500 })
|
||||
}, [])
|
||||
|
||||
const handleDeleteMarker = useCallback(
|
||||
(id: number) => {
|
||||
if (selectedMarkerId === id) setSelectedMarkerId(null)
|
||||
deleteMarker(id)
|
||||
},
|
||||
[selectedMarkerId, deleteMarker]
|
||||
)
|
||||
|
||||
const selectedMarker = selectedMarkerId ? markers.find((m) => m.id === selectedMarkerId) : null
|
||||
|
||||
return (
|
||||
<MapProvider>
|
||||
<Map
|
||||
ref={mapRef}
|
||||
reuseMaps
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100vh',
|
||||
}}
|
||||
mapStyle={`http://${window.location.hostname}:${window.location.port}/api/maps/styles`}
|
||||
mapStyle={`${window.location.protocol}//${window.location.hostname}:${window.location.port}/api/maps/styles`}
|
||||
mapLib={maplibregl}
|
||||
initialViewState={{
|
||||
longitude: -101,
|
||||
latitude: 40,
|
||||
zoom: 3.5,
|
||||
}}
|
||||
onClick={handleMapClick}
|
||||
>
|
||||
<NavigationControl style={{ marginTop: '110px', marginRight: '36px' }} />
|
||||
<FullscreenControl style={{ marginTop: '30px', marginRight: '36px' }} />
|
||||
<ScaleControl position="bottom-left" maxWidth={150} unit={scaleUnit} />
|
||||
<div style={{ position: 'absolute', bottom: '30px', left: '10px', zIndex: 2 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
borderRadius: '4px',
|
||||
boxShadow: '0 0 0 2px rgba(0,0,0,0.1)',
|
||||
overflow: 'hidden',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => { if (scaleUnit !== 'metric') toggleScaleUnit() }}
|
||||
style={{
|
||||
background: scaleUnit === 'metric' ? '#424420' : 'white',
|
||||
color: scaleUnit === 'metric' ? 'white' : '#666',
|
||||
border: 'none',
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Metric
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (scaleUnit !== 'imperial') toggleScaleUnit() }}
|
||||
style={{
|
||||
background: scaleUnit === 'imperial' ? '#424420' : 'white',
|
||||
color: scaleUnit === 'imperial' ? 'white' : '#666',
|
||||
border: 'none',
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Imperial
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Existing markers */}
|
||||
{markers.map((marker) => (
|
||||
<Marker
|
||||
key={marker.id}
|
||||
longitude={marker.longitude}
|
||||
latitude={marker.latitude}
|
||||
anchor="bottom"
|
||||
onClick={(e) => {
|
||||
e.originalEvent.stopPropagation()
|
||||
setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id)
|
||||
setPlacingMarker(null)
|
||||
}}
|
||||
>
|
||||
<MarkerPin
|
||||
color={PIN_COLORS.find((c) => c.id === marker.color)?.hex}
|
||||
active={marker.id === selectedMarkerId}
|
||||
/>
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{/* Popup for selected marker */}
|
||||
{selectedMarker && (
|
||||
<Popup
|
||||
longitude={selectedMarker.longitude}
|
||||
latitude={selectedMarker.latitude}
|
||||
anchor="bottom"
|
||||
offset={[0, -36] as [number, number]}
|
||||
onClose={() => setSelectedMarkerId(null)}
|
||||
closeOnClick={false}
|
||||
>
|
||||
<div className="text-sm font-medium">{selectedMarker.name}</div>
|
||||
</Popup>
|
||||
)}
|
||||
|
||||
{/* Popup for placing a new marker */}
|
||||
{placingMarker && (
|
||||
<Popup
|
||||
longitude={placingMarker.lng}
|
||||
latitude={placingMarker.lat}
|
||||
anchor="bottom"
|
||||
onClose={() => setPlacingMarker(null)}
|
||||
closeOnClick={false}
|
||||
>
|
||||
<div className="p-1">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="Name this location"
|
||||
value={markerName}
|
||||
onChange={(e) => setMarkerName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSaveMarker()
|
||||
if (e.key === 'Escape') setPlacingMarker(null)
|
||||
}}
|
||||
className="block w-full rounded border border-gray-300 px-2 py-1 text-sm placeholder:text-gray-400 focus:outline-none focus:border-gray-500"
|
||||
/>
|
||||
<div className="mt-1.5 flex gap-1 items-center">
|
||||
{PIN_COLORS.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setMarkerColor(c.id)}
|
||||
title={c.label}
|
||||
className="rounded-full p-0.5 transition-transform"
|
||||
style={{
|
||||
outline: markerColor === c.id ? `2px solid ${c.hex}` : '2px solid transparent',
|
||||
outlineOffset: '1px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-4 h-4 rounded-full"
|
||||
style={{ backgroundColor: c.hex }}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1.5 flex gap-1.5 justify-end">
|
||||
<button
|
||||
onClick={() => setPlacingMarker(null)}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 px-2 py-1 rounded transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveMarker}
|
||||
disabled={!markerName.trim()}
|
||||
className="text-xs bg-[#424420] text-white rounded px-2.5 py-1 hover:bg-[#525530] disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</Map>
|
||||
|
||||
{/* Marker panel overlay */}
|
||||
<MarkerPanel
|
||||
markers={markers}
|
||||
onDelete={handleDeleteMarker}
|
||||
onFlyTo={handleFlyTo}
|
||||
onSelect={setSelectedMarkerId}
|
||||
selectedMarkerId={selectedMarkerId}
|
||||
/>
|
||||
</MapProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
116
admin/inertia/components/maps/MarkerPanel.tsx
Normal file
116
admin/inertia/components/maps/MarkerPanel.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { useState } from 'react'
|
||||
import { IconMapPinFilled, IconTrash, IconMapPin, IconX } from '@tabler/icons-react'
|
||||
import { PIN_COLORS } from '~/hooks/useMapMarkers'
|
||||
import type { MapMarker } from '~/hooks/useMapMarkers'
|
||||
|
||||
interface MarkerPanelProps {
|
||||
markers: MapMarker[]
|
||||
onDelete: (id: number) => void
|
||||
onFlyTo: (longitude: number, latitude: number) => void
|
||||
onSelect: (id: number | null) => void
|
||||
selectedMarkerId: number | null
|
||||
}
|
||||
|
||||
export default function MarkerPanel({
|
||||
markers,
|
||||
onDelete,
|
||||
onFlyTo,
|
||||
onSelect,
|
||||
selectedMarkerId,
|
||||
}: MarkerPanelProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="absolute left-4 top-[72px] z-40 flex items-center gap-1.5 rounded-lg bg-surface-primary/95 px-3 py-2 shadow-lg border border-border-subtle backdrop-blur-sm hover:bg-surface-secondary transition-colors"
|
||||
title="Show saved locations"
|
||||
>
|
||||
<IconMapPin size={18} className="text-desert-orange" />
|
||||
<span className="text-sm font-medium text-text-primary">Pins</span>
|
||||
{markers.length > 0 && (
|
||||
<span className="ml-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-desert-orange text-[11px] font-bold text-white px-1">
|
||||
{markers.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute left-4 top-[72px] z-40 w-72 rounded-lg bg-surface-primary/95 shadow-lg border border-border-subtle backdrop-blur-sm">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2.5 border-b border-border-subtle">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconMapPin size={18} className="text-desert-orange" />
|
||||
<span className="text-sm font-semibold text-text-primary">
|
||||
Saved Locations
|
||||
</span>
|
||||
{markers.length > 0 && (
|
||||
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-desert-orange text-[11px] font-bold text-white px-1">
|
||||
{markers.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded p-0.5 text-text-muted hover:text-text-primary hover:bg-surface-secondary transition-colors"
|
||||
title="Close panel"
|
||||
>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Marker list */}
|
||||
<div className="max-h-[calc(100vh-180px)] overflow-y-auto">
|
||||
{markers.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center">
|
||||
<IconMapPinFilled size={24} className="mx-auto mb-2 text-text-muted" />
|
||||
<p className="text-sm text-text-muted">
|
||||
Click anywhere on the map to drop a pin
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul>
|
||||
{markers.map((marker) => (
|
||||
<li
|
||||
key={marker.id}
|
||||
className={`flex items-center gap-2 px-3 py-2 border-b border-border-subtle last:border-b-0 group transition-colors ${
|
||||
marker.id === selectedMarkerId
|
||||
? 'bg-desert-green/10'
|
||||
: 'hover:bg-surface-secondary'
|
||||
}`}
|
||||
>
|
||||
<IconMapPinFilled
|
||||
size={16}
|
||||
className="shrink-0"
|
||||
style={{ color: PIN_COLORS.find((c) => c.id === marker.color)?.hex ?? '#a84a12' }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
onSelect(marker.id)
|
||||
onFlyTo(marker.longitude, marker.latitude)
|
||||
}}
|
||||
className="flex-1 min-w-0 text-left"
|
||||
title={marker.name}
|
||||
>
|
||||
<p className="text-sm font-medium text-text-primary truncate">
|
||||
{marker.name}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(marker.id)}
|
||||
className="shrink-0 rounded p-1 text-text-muted opacity-0 group-hover:opacity-100 hover:text-desert-red hover:bg-surface-secondary transition-all"
|
||||
title="Delete pin"
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
17
admin/inertia/components/maps/MarkerPin.tsx
Normal file
17
admin/inertia/components/maps/MarkerPin.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { IconMapPinFilled } from '@tabler/icons-react'
|
||||
|
||||
interface MarkerPinProps {
|
||||
color?: string
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
export default function MarkerPin({ color = '#a84a12', active = false }: MarkerPinProps) {
|
||||
return (
|
||||
<div className="cursor-pointer" style={{ filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.4))' }}>
|
||||
<IconMapPinFilled
|
||||
size={active ? 36 : 32}
|
||||
style={{ color }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -9,11 +9,11 @@ export function Table({ children }: { children: React.ReactNode }) {
|
|||
}
|
||||
|
||||
export function TableHead({ children }: { children: React.ReactNode }) {
|
||||
return <thead className="bg-desert-green-dark">{children}</thead>
|
||||
return <thead className="bg-desert-green">{children}</thead>
|
||||
}
|
||||
|
||||
export function TableBody({ children }: { children: React.ReactNode }) {
|
||||
return <tbody className="divide-y divide-desert-tan-lighter/50 bg-white">{children}</tbody>
|
||||
return <tbody className="divide-y divide-desert-tan-lighter/50 bg-surface-primary">{children}</tbody>
|
||||
}
|
||||
|
||||
export function TableRow({ children }: { children: React.ReactNode }) {
|
||||
|
|
@ -22,7 +22,7 @@ export function TableRow({ children }: { children: React.ReactNode }) {
|
|||
|
||||
export function TableHeader({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<th className="px-5 py-3 text-left text-sm font-semibold text-desert-white tracking-wide">
|
||||
<th className="px-5 py-3 text-left text-sm font-semibold text-white tracking-wide">
|
||||
{children}
|
||||
</th>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -45,8 +45,8 @@ export default function InfoCard({ title, icon, data, variant = 'default' }: Inf
|
|||
/>
|
||||
|
||||
<div className="relative flex items-center gap-3">
|
||||
{icon && <div className="text-desert-white opacity-80">{icon}</div>}
|
||||
<h3 className="text-lg font-bold text-desert-white uppercase tracking-wide">{title}</h3>
|
||||
{icon && <div className="text-white opacity-80">{icon}</div>}
|
||||
<h3 className="text-lg font-bold text-white uppercase tracking-wide">{title}</h3>
|
||||
</div>
|
||||
<div className="absolute top-0 right-0 w-24 h-24 transform translate-x-8 -translate-y-8">
|
||||
<div className="w-full h-full bg-desert-green-dark opacity-30 transform rotate-45" />
|
||||
|
|
|
|||
|
|
@ -34,8 +34,125 @@
|
|||
--color-desert-stone: #75756a;
|
||||
--color-desert-stone-light: #8f8f82;
|
||||
--color-desert-stone-lighter: #afafa5;
|
||||
|
||||
/* Semantic surface/text tokens (for replacing generic gray/white Tailwind classes) */
|
||||
--color-surface-primary: #ffffff;
|
||||
--color-surface-secondary: #f9fafb;
|
||||
--color-surface-elevated: #ffffff;
|
||||
--color-text-primary: #111827;
|
||||
--color-text-secondary: #6b7280;
|
||||
--color-text-muted: #9ca3af;
|
||||
--color-border-default: #d1d5db;
|
||||
--color-border-subtle: #e5e7eb;
|
||||
|
||||
/* Button interactive states (green hover/active swap conflicts with text color inversion) */
|
||||
--color-btn-green-hover: #353518;
|
||||
--color-btn-green-active: #2a2a15;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-desert-sand);
|
||||
color: var(--color-text-primary);
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Night Ops — warm charcoal dark mode */
|
||||
[data-theme="dark"] {
|
||||
/* Backgrounds: light sand → warm charcoal */
|
||||
--color-desert-sand: #1c1b16;
|
||||
--color-desert-white: #2a2918;
|
||||
|
||||
/* Text greens: dark text → light text for readability */
|
||||
--color-desert-green-darker: #f7eedc;
|
||||
--color-desert-green-dark: #e8dfc8;
|
||||
|
||||
/* Accent green: slightly brighter for dark bg visibility */
|
||||
--color-desert-green: #525530;
|
||||
|
||||
/* Light variants → dark variants (hover bg, disabled states) */
|
||||
--color-desert-green-light: #3a3c24;
|
||||
--color-desert-green-lighter: #2d2e1c;
|
||||
|
||||
/* Orange: brighter for contrast on dark surfaces */
|
||||
--color-desert-orange-dark: #c85815;
|
||||
--color-desert-orange: #c85815;
|
||||
--color-desert-orange-light: #e69556;
|
||||
--color-desert-orange-lighter: #f0b87a;
|
||||
|
||||
/* Tan: lightened for readability */
|
||||
--color-desert-tan-dark: #c9b99f;
|
||||
--color-desert-tan: #a8927a;
|
||||
--color-desert-tan-light: #8b7355;
|
||||
--color-desert-tan-lighter: #6b5d4f;
|
||||
|
||||
/* Red: lightened for dark bg */
|
||||
--color-desert-red-dark: #d88989;
|
||||
--color-desert-red: #b05555;
|
||||
--color-desert-red-light: #994444;
|
||||
--color-desert-red-lighter: #7a2e2e;
|
||||
|
||||
/* Olive: lightened */
|
||||
--color-desert-olive-dark: #a5ab7d;
|
||||
--color-desert-olive: #858a55;
|
||||
--color-desert-olive-light: #6d7042;
|
||||
--color-desert-olive-lighter: #5a5c3a;
|
||||
|
||||
/* Stone: lightened */
|
||||
--color-desert-stone-dark: #afafa5;
|
||||
--color-desert-stone: #8f8f82;
|
||||
--color-desert-stone-light: #75756a;
|
||||
--color-desert-stone-lighter: #5c5c54;
|
||||
|
||||
/* Semantic surface overrides */
|
||||
--color-surface-primary: #2a2918;
|
||||
--color-surface-secondary: #353420;
|
||||
--color-surface-elevated: #3d3c2a;
|
||||
--color-text-primary: #f7eedc;
|
||||
--color-text-secondary: #afafa5;
|
||||
--color-text-muted: #8f8f82;
|
||||
--color-border-default: #424420;
|
||||
--color-border-subtle: #353420;
|
||||
|
||||
/* Button interactive states: darker green for hover/active on dark bg */
|
||||
--color-btn-green-hover: #474a28;
|
||||
--color-btn-green-active: #3a3c24;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* MapLibre popup styling for dark mode */
|
||||
[data-theme="dark"] .maplibregl-popup-content {
|
||||
background: #2a2918;
|
||||
color: #f7eedc;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .maplibregl-popup-content input {
|
||||
background: #353420;
|
||||
color: #f7eedc;
|
||||
border-color: #424420;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .maplibregl-popup-content input::placeholder {
|
||||
color: #8f8f82;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .maplibregl-popup-tip {
|
||||
border-top-color: #2a2918;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .maplibregl-popup-anchor-bottom .maplibregl-popup-tip {
|
||||
border-top-color: #2a2918;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .maplibregl-popup-anchor-top .maplibregl-popup-tip {
|
||||
border-bottom-color: #2a2918;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .maplibregl-popup-close-button {
|
||||
color: #afafa5;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .maplibregl-popup-close-button:hover {
|
||||
color: #f7eedc;
|
||||
background: #353420;
|
||||
}
|
||||
82
admin/inertia/hooks/useDiskDisplayData.ts
Normal file
82
admin/inertia/hooks/useDiskDisplayData.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { NomadDiskInfo } from '../../types/system'
|
||||
import { Systeminformation } from 'systeminformation'
|
||||
import { formatBytes } from '~/lib/util'
|
||||
|
||||
type DiskDisplayItem = {
|
||||
label: string
|
||||
value: number
|
||||
total: string
|
||||
used: string
|
||||
subtext: string
|
||||
totalBytes: number
|
||||
usedBytes: number
|
||||
}
|
||||
|
||||
/** Get all valid disks formatted for display (settings/system page) */
|
||||
export function getAllDiskDisplayItems(
|
||||
disks: NomadDiskInfo[] | undefined,
|
||||
fsSize: Systeminformation.FsSizeData[] | undefined
|
||||
): DiskDisplayItem[] {
|
||||
const validDisks = disks?.filter((d) => d.totalSize > 0) || []
|
||||
|
||||
if (validDisks.length > 0) {
|
||||
return validDisks.map((disk) => ({
|
||||
label: disk.name || 'Unknown',
|
||||
value: disk.percentUsed || 0,
|
||||
total: formatBytes(disk.totalSize),
|
||||
used: formatBytes(disk.totalUsed),
|
||||
subtext: `${formatBytes(disk.totalUsed || 0)} / ${formatBytes(disk.totalSize || 0)}`,
|
||||
totalBytes: disk.totalSize,
|
||||
usedBytes: disk.totalUsed,
|
||||
}))
|
||||
}
|
||||
|
||||
if (fsSize && fsSize.length > 0) {
|
||||
const seen = new Set<number>()
|
||||
const uniqueFs = fsSize.filter((fs) => {
|
||||
if (fs.size <= 0 || seen.has(fs.size)) return false
|
||||
seen.add(fs.size)
|
||||
return true
|
||||
})
|
||||
const realDevices = uniqueFs.filter((fs) => fs.fs.startsWith('/dev/'))
|
||||
const displayFs = realDevices.length > 0 ? realDevices : uniqueFs
|
||||
return displayFs.map((fs) => ({
|
||||
label: fs.fs || 'Unknown',
|
||||
value: fs.use || 0,
|
||||
total: formatBytes(fs.size),
|
||||
used: formatBytes(fs.used),
|
||||
subtext: `${formatBytes(fs.used)} / ${formatBytes(fs.size)}`,
|
||||
totalBytes: fs.size,
|
||||
usedBytes: fs.used,
|
||||
}))
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
/** Get primary disk info for storage projection (easy-setup page) */
|
||||
export function getPrimaryDiskInfo(
|
||||
disks: NomadDiskInfo[] | undefined,
|
||||
fsSize: Systeminformation.FsSizeData[] | undefined
|
||||
): { totalSize: number; totalUsed: number } | null {
|
||||
const validDisks = disks?.filter((d) => d.totalSize > 0) || []
|
||||
if (validDisks.length > 0) {
|
||||
const diskWithRoot = validDisks.find((d) =>
|
||||
d.filesystems?.some((fs) => fs.mount === '/' || fs.mount === '/storage')
|
||||
)
|
||||
const primary =
|
||||
diskWithRoot || validDisks.reduce((a, b) => (b.totalSize > a.totalSize ? b : a))
|
||||
return { totalSize: primary.totalSize, totalUsed: primary.totalUsed }
|
||||
}
|
||||
|
||||
if (fsSize && fsSize.length > 0) {
|
||||
const realDevices = fsSize.filter((fs) => fs.fs.startsWith('/dev/'))
|
||||
const primary =
|
||||
realDevices.length > 0
|
||||
? realDevices.reduce((a, b) => (b.size > a.size ? b : a))
|
||||
: fsSize[0]
|
||||
return { totalSize: primary.size, totalUsed: primary.used }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -17,7 +17,11 @@ const useDownloads = (props: useDownloadsProps) => {
|
|||
const queryData = useQuery({
|
||||
queryKey: queryKey,
|
||||
queryFn: () => api.listDownloadJobs(props.filetype),
|
||||
refetchInterval: 2000, // Refetch every 2 seconds to get updated progress
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data
|
||||
// Only poll when there are active downloads; otherwise use a slower interval
|
||||
return data && data.length > 0 ? 2000 : 30000
|
||||
},
|
||||
enabled: props.enabled ?? true,
|
||||
})
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user