mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-03-28 11:39:26 +01:00
Compare commits
285 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
3491dda753 | ||
|
|
25f4ed37e6 | ||
|
|
62e33aeff5 | ||
|
|
e7ab2b197c | ||
|
|
63e1f56aa0 | ||
|
|
9422c76bc6 | ||
|
|
a77edcaac3 | ||
|
|
99561b420f | ||
|
|
96e5027055 | ||
|
|
460756f581 | ||
|
|
6f0fae0033 | ||
|
|
41c64fb50b | ||
|
|
d30c1a1407 | ||
|
|
9c74339893 | ||
|
|
be25408fe7 | ||
|
|
5d3c659d05 | ||
|
|
75106a8f61 | ||
|
|
b9dd32be25 | ||
|
|
58b106f388 | ||
|
|
7db8568e19 | ||
|
|
20a313ce08 | ||
|
|
650ae407f3 | ||
|
|
db69428193 | ||
|
|
bc016e6c60 | ||
|
|
45a30c0188 | ||
|
|
0e94d5daa4 | ||
|
|
744504dd1e | ||
|
|
e1c808f90d | ||
|
|
c1395794d4 | ||
|
|
a105ac1a83 | ||
|
|
bc7f84c123 | ||
|
|
dfa896e86b | ||
|
|
99b96c3df7 | ||
|
|
80ae0aacf8 | ||
|
|
d9d3d2e068 | ||
|
|
56b0d69421 | ||
|
|
782985bac0 | ||
|
|
96beab7e69 | ||
|
|
b806cefe3a | ||
|
|
e2b447e142 | ||
|
|
639b026e6f | ||
|
|
617dc111c2 | ||
|
|
d4a50f3e9c | ||
|
|
efa57ec010 | ||
|
|
6817e2e47e | ||
|
|
e12e7c1696 | ||
|
|
fbfaf5fdae | ||
|
|
00bd864831 | ||
|
|
41eb30d84d | ||
|
|
6874a2824f | ||
|
|
a3f10dd158 | ||
|
|
76fcbe46fa | ||
|
|
765207f956 | ||
|
|
7a3c4bfbba | ||
|
|
6af9b46e4e | ||
|
|
6cb1cfe727 | ||
|
|
83d328a29a | ||
|
|
485d34e0c8 | ||
|
|
98b65c421c | ||
|
|
16ce1e2945 | ||
|
|
0ee0dc13e8 | ||
|
|
5840bfc24b | ||
|
|
cdf931be2f | ||
|
|
ed26df7aff | ||
|
|
e75d54bd69 | ||
|
|
43ebaa93c1 | ||
|
|
77f1868cf8 | ||
|
|
3ee3cffad9 | ||
|
|
b2e4ce7261 | ||
|
|
ad31a985ea | ||
|
|
b63c33d277 | ||
|
|
8b0add66d9 | ||
|
|
8609a551f2 | ||
|
|
fcb696587a | ||
|
|
a49322b63b | ||
|
|
76ac713406 | ||
|
|
0177f25b1f | ||
|
|
279ee1254c | ||
|
|
d55ff7b466 | ||
|
|
c4514e8c3d | ||
|
|
d7d3821c06 | ||
|
|
32d206cfd7 | ||
|
|
4ac261477a | ||
|
|
4425e02c3c | ||
|
|
df6247b425 | ||
|
|
988dba318c | ||
|
|
f02c5e5cd0 | ||
|
|
7f136c6441 | ||
|
|
f090468d20 | ||
|
|
3f41c8801c | ||
|
|
cf8c94ddb2 | ||
|
|
4747863702 | ||
|
|
9301c44d3f | ||
|
|
276bdcd0b2 | ||
|
|
921eef30d6 | ||
|
|
c16cfc3a93 | ||
|
|
812d13c3da | ||
|
|
b0be99700d | ||
|
|
569dae057d | ||
|
|
f8117ede68 | ||
|
|
6745dbf3d1 | ||
|
|
8726700a0a | ||
|
|
c2b6e079af | ||
|
|
711bd07f7b | ||
|
|
12286b9d34 | ||
|
|
2e0ab10075 | ||
|
|
40741530fd | ||
|
|
1a95b84a8c | ||
|
|
8b8e00de8b | ||
|
|
f3c16c674c | ||
|
|
184e96df06 | ||
|
|
c4730511c9 | ||
|
|
a8f41298fd | ||
|
|
ceaba61574 | ||
|
|
aadaac8169 | ||
|
|
d9ed8e9602 | ||
|
|
efb4db4fa8 | ||
|
|
3dde0c149b | ||
|
|
474ca2a76b | ||
|
|
3c3684497b | ||
|
|
36b6d8ed7a | ||
|
|
fcc749ec57 | ||
|
|
52e90041f4 | ||
|
|
c3278efc01 | ||
|
|
6b17e6ff68 | ||
|
|
d63c5bc668 | ||
|
|
5e584eb5d0 | ||
|
|
cc61fbea3b | ||
|
|
bfc6c3d113 | ||
|
|
a91c13867d | ||
|
|
d4cbc0c2d5 | ||
|
|
1952d585d3 | ||
|
|
fa8300b5df | ||
|
|
42568a9e7e | ||
|
|
ab07551719 | ||
|
|
907982062f | ||
|
|
5de3c5f261 | ||
|
|
18e55c747a | ||
|
|
738b57e854 | ||
|
|
2c4fc59428 | ||
|
|
3b31be66f9 | ||
|
|
9731ce839d | ||
|
|
a697d930fe | ||
|
|
d1f40663d3 | ||
|
|
1923cd4cde | ||
|
|
029c2176f7 | ||
|
|
31c671bdb5 | ||
|
|
4584844ca6 | ||
|
|
a2aa33168d | ||
|
|
68f374e3a8 | ||
|
|
80aa556b42 | ||
|
|
f6db05bed2 | ||
|
|
b9ebc6c54e | ||
|
|
adf76d272e | ||
|
|
0da050c5a3 | ||
|
|
243f749090 | ||
|
|
50174d2edb | ||
|
|
c78736c8da | ||
|
|
cb85785cb1 | ||
|
|
e8cc17a20d | ||
|
|
2921017191 | ||
|
|
9a93fc9e04 | ||
|
|
e8aabfce1e | ||
|
|
08f9722b59 | ||
|
|
fb09ff3daf | ||
|
|
8cfe490b57 | ||
|
|
c8de767052 | ||
|
|
e7336f2a8e | ||
|
|
7a5a254dd5 | ||
|
|
8f1b8de792 | ||
|
|
1b31c6f80d | ||
|
|
5afc3a270a | ||
|
|
64e6e11389 | ||
|
|
e31f956289 | ||
|
|
565abca821 | ||
|
|
8ae47e03d8 | ||
|
|
b94deef437 | ||
|
|
42a18c8dc6 | ||
|
|
4ef954c9b5 | ||
|
|
f49b9abb81 | ||
|
|
d5db024eee | ||
|
|
a42b6b85f6 | ||
|
|
525eecbbde | ||
|
|
8092fb58d8 | ||
|
|
438d683bac | ||
|
|
6efd049424 | ||
|
|
755807f95e | ||
|
|
6bee84f367 | ||
|
|
24f10ea3d5 | ||
|
|
6c650a0ded | ||
|
|
6236b29e1c | ||
|
|
33f04728c5 | ||
|
|
9bb4ff5afc | ||
|
|
04e169fe7b | ||
|
|
937da5d869 | ||
|
|
b3ef977484 | ||
|
|
b6e6e10328 | ||
|
|
111ad5aec8 |
8
.dockerignore
Normal file
8
.dockerignore
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
.env
|
||||
.env.*
|
||||
.git
|
||||
node_modules
|
||||
*.log
|
||||
admin/storage
|
||||
admin/node_modules
|
||||
admin/build
|
||||
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
|
||||
7
.github/dependabot.yaml
vendored
Normal file
7
.github/dependabot.yaml
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/admin"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
target-branch: "rc"
|
||||
133
.github/scripts/finalize-release-notes.sh
vendored
Executable file
133
.github/scripts/finalize-release-notes.sh
vendored
Executable file
|
|
@ -0,0 +1,133 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# finalize-release-notes.sh
|
||||
#
|
||||
# Stamps the "## Unreleased" section in a release-notes file with a version
|
||||
# and date, and extracts the section content for use in GitHub releases / email.
|
||||
# Also includes all commits since the last release for complete transparency.
|
||||
#
|
||||
# Usage: finalize-release-notes.sh <version> <file-path>
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 - Success: section stamped and extracted
|
||||
# 1 - No "## Unreleased" section found (skip gracefully)
|
||||
# 2 - Unreleased section exists but is empty (skip gracefully)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:?Usage: finalize-release-notes.sh <version> <file-path>}"
|
||||
FILE="${2:?Usage: finalize-release-notes.sh <version> <file-path>}"
|
||||
|
||||
if [[ ! -f "$FILE" ]]; then
|
||||
echo "Error: File not found: $FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find the line number of the ## Unreleased header (case-insensitive)
|
||||
HEADER_LINE=$(grep -inm1 '^## unreleased' "$FILE" | cut -d: -f1)
|
||||
|
||||
if [[ -z "$HEADER_LINE" ]]; then
|
||||
echo "No '## Unreleased' section found. Skipping."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TOTAL_LINES=$(wc -l < "$FILE")
|
||||
|
||||
# Find the next section header (## Version ...) or --- separator after the Unreleased header
|
||||
NEXT_SECTION_LINE=""
|
||||
if [[ $HEADER_LINE -lt $TOTAL_LINES ]]; then
|
||||
NEXT_SECTION_LINE=$(tail -n +"$((HEADER_LINE + 1))" "$FILE" \
|
||||
| grep -nm1 '^## \|^---$' \
|
||||
| cut -d: -f1)
|
||||
fi
|
||||
|
||||
if [[ -n "$NEXT_SECTION_LINE" ]]; then
|
||||
# NEXT_SECTION_LINE is relative to HEADER_LINE+1, convert to absolute
|
||||
END_LINE=$((HEADER_LINE + NEXT_SECTION_LINE - 1))
|
||||
else
|
||||
# Section runs to end of file
|
||||
END_LINE=$TOTAL_LINES
|
||||
fi
|
||||
|
||||
# Extract content between header and next section (exclusive of both boundaries)
|
||||
CONTENT_START=$((HEADER_LINE + 1))
|
||||
CONTENT_END=$END_LINE
|
||||
|
||||
# Extract the section body (between header line and the next boundary)
|
||||
SECTION_BODY=$(sed -n "${CONTENT_START},${CONTENT_END}p" "$FILE" | sed '/^$/N;/^\n$/d')
|
||||
|
||||
# Check for actual content: strip blank lines and lines that are only markdown headers (###...)
|
||||
TRIMMED=$(echo "$SECTION_BODY" | sed '/^[[:space:]]*$/d')
|
||||
HAS_CONTENT=$(echo "$SECTION_BODY" | sed '/^[[:space:]]*$/d' | grep -v '^###' || true)
|
||||
|
||||
if [[ -z "$TRIMMED" || -z "$HAS_CONTENT" ]]; then
|
||||
echo "Unreleased section is empty. Skipping."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Format the date as "Month Day, Year"
|
||||
DATE_STAMP=$(date +'%B %-d, %Y')
|
||||
NEW_HEADER="## Version ${VERSION} - ${DATE_STAMP}"
|
||||
|
||||
# Build the replacement: swap the header line, keep everything else intact
|
||||
{
|
||||
# Lines before the Unreleased header
|
||||
if [[ $HEADER_LINE -gt 1 ]]; then
|
||||
head -n "$((HEADER_LINE - 1))" "$FILE"
|
||||
fi
|
||||
# New versioned header
|
||||
echo "$NEW_HEADER"
|
||||
# Content between header and next section
|
||||
sed -n "${CONTENT_START},${CONTENT_END}p" "$FILE"
|
||||
# Rest of the file after the section
|
||||
if [[ $END_LINE -lt $TOTAL_LINES ]]; then
|
||||
tail -n +"$((END_LINE + 1))" "$FILE"
|
||||
fi
|
||||
} > "${FILE}.tmp"
|
||||
|
||||
mv "${FILE}.tmp" "$FILE"
|
||||
|
||||
# Get commits since the last release
|
||||
LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||
COMMIT_LIST=""
|
||||
|
||||
if [[ -n "$LAST_TAG" ]]; then
|
||||
echo "Fetching commits since ${LAST_TAG}..."
|
||||
# Get commits between last tag and HEAD, excluding merge commits and skip ci commits
|
||||
COMMIT_LIST=$(git log "${LAST_TAG}..HEAD" \
|
||||
--no-merges \
|
||||
--pretty=format:"- %s ([%h](https://github.com/${GITHUB_REPOSITORY}/commit/%H))" \
|
||||
--grep="\[skip ci\]" --invert-grep \
|
||||
|| echo "")
|
||||
else
|
||||
echo "No previous tag found, fetching all commits..."
|
||||
COMMIT_LIST=$(git log \
|
||||
--no-merges \
|
||||
--pretty=format:"- %s ([%h](https://github.com/${GITHUB_REPOSITORY}/commit/%H))" \
|
||||
--grep="\[skip ci\]" --invert-grep \
|
||||
|| echo "")
|
||||
fi
|
||||
|
||||
# Write the extracted section content (for GitHub release body / future email)
|
||||
{
|
||||
echo "$NEW_HEADER"
|
||||
echo ""
|
||||
if [[ -n "$TRIMMED" ]]; then
|
||||
echo "$TRIMMED"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Add commit history if available
|
||||
if [[ -n "$COMMIT_LIST" ]]; then
|
||||
echo "---"
|
||||
echo ""
|
||||
echo "### 📝 All Changes"
|
||||
echo ""
|
||||
echo "$COMMIT_LIST"
|
||||
fi
|
||||
} > "${FILE}.section"
|
||||
|
||||
echo "Finalized release notes for v${VERSION}"
|
||||
echo " Updated: ${FILE}"
|
||||
echo " Extracted: ${FILE}.section"
|
||||
exit 0
|
||||
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@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-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,12 +1,17 @@
|
|||
name: Build Docker Image
|
||||
name: Build Primary Docker Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Semantic version to label the Docker image under'
|
||||
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? (Keep false for RC and beta releases)'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
check_authorization:
|
||||
|
|
@ -28,7 +33,7 @@ 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
|
||||
with:
|
||||
|
|
@ -41,4 +46,9 @@ jobs:
|
|||
push: true
|
||||
tags: |
|
||||
ghcr.io/crosstalk-solutions/project-nomad:${{ inputs.version }}
|
||||
ghcr.io/crosstalk-solutions/project-nomad:latest
|
||||
ghcr.io/crosstalk-solutions/project-nomad:v${{ inputs.version }}
|
||||
${{ inputs.tag_latest && 'ghcr.io/crosstalk-solutions/project-nomad:latest' || '' }}
|
||||
build-args: |
|
||||
VERSION=${{ inputs.version }}
|
||||
BUILD_DATE=${{ github.event.workflow_run.created_at }}
|
||||
VCS_REF=${{ github.sha }}
|
||||
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' || '' }}
|
||||
58
.github/workflows/release.yml
vendored
58
.github/workflows/release.yml
vendored
|
|
@ -22,7 +22,7 @@ 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
|
||||
|
|
@ -34,4 +34,58 @@ jobs:
|
|||
GIT_AUTHOR_NAME: cosmistack-bot
|
||||
GIT_AUTHOR_EMAIL: dev@cosmistack.com
|
||||
GIT_COMMITTER_NAME: cosmistack-bot
|
||||
GIT_COMMITTER_EMAIL: dev@cosmistack.com
|
||||
GIT_COMMITTER_EMAIL: dev@cosmistack.com
|
||||
|
||||
- name: Finalize release notes
|
||||
# Skip for pre-releases (versions containing a hyphen, e.g. 1.27.0-rc.1)
|
||||
if: |
|
||||
steps.semver.outputs.new_release_published == 'true' &&
|
||||
!contains(steps.semver.outputs.new_release_version, '-')
|
||||
id: finalize-notes
|
||||
env:
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
git pull origin main
|
||||
chmod +x .github/scripts/finalize-release-notes.sh
|
||||
EXIT_CODE=0
|
||||
.github/scripts/finalize-release-notes.sh \
|
||||
"${{ steps.semver.outputs.new_release_version }}" \
|
||||
admin/docs/release-notes.md || EXIT_CODE=$?
|
||||
if [[ "$EXIT_CODE" -eq 0 ]]; then
|
||||
echo "has_notes=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_notes=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Commit finalized release notes
|
||||
if: |
|
||||
steps.semver.outputs.new_release_published == 'true' &&
|
||||
steps.finalize-notes.outputs.has_notes == 'true' &&
|
||||
!contains(steps.semver.outputs.new_release_version, '-')
|
||||
run: |
|
||||
git config user.name "cosmistack-bot"
|
||||
git config user.email "dev@cosmistack.com"
|
||||
git remote set-url origin https://x-access-token:${{ secrets.COSMISTACKBOT_ACCESS_TOKEN }}@github.com/${{ github.repository }}.git
|
||||
git add admin/docs/release-notes.md
|
||||
git commit -m "docs(release): finalize v${{ steps.semver.outputs.new_release_version }} release notes [skip ci]"
|
||||
git push origin main
|
||||
|
||||
- name: Update GitHub release body
|
||||
if: |
|
||||
steps.semver.outputs.new_release_published == 'true' &&
|
||||
steps.finalize-notes.outputs.has_notes == 'true' &&
|
||||
!contains(steps.semver.outputs.new_release_version, '-')
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.COSMISTACKBOT_ACCESS_TOKEN }}
|
||||
run: |
|
||||
gh release edit "v${{ steps.semver.outputs.new_release_version }}" \
|
||||
--notes-file admin/docs/release-notes.md.section
|
||||
|
||||
# Future: Send release notes email
|
||||
# - name: Send release notes email
|
||||
# if: steps.semver.outputs.new_release_published == 'true' && steps.finalize-notes.outputs.has_notes == 'true'
|
||||
# run: |
|
||||
# curl -X POST "https://api.projectnomad.us/api/v1/newsletter/release" \
|
||||
# -H "Authorization: Bearer ${{ secrets.NOMAD_API_KEY }}" \
|
||||
# -H "Content-Type: application/json" \
|
||||
# -d "{\"version\": \"${{ steps.semver.outputs.new_release_version }}\", \"body\": $(cat admin/docs/release-notes.md.section | jq -Rs .)}"
|
||||
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
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
{
|
||||
"branches": ["master"],
|
||||
"branches": [
|
||||
"main",
|
||||
{ "name": "rc", "prerelease": "rc" }
|
||||
],
|
||||
"plugins": [
|
||||
"@semantic-release/commit-analyzer",
|
||||
"@semantic-release/release-notes-generator",
|
||||
|
|
|
|||
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.
|
||||
187
CONTRIBUTING.md
Normal file
187
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
# 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 main
|
||||
git rebase upstream/main
|
||||
```
|
||||
|
||||
2. **Create a feature branch** off `main` 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.
|
||||
|
||||
When your changes include anything user-facing, **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.
|
||||
|
||||
**Example:**
|
||||
```markdown
|
||||
## Unreleased
|
||||
|
||||
### Features
|
||||
- **Maps**: Added support for downloading South America regional maps
|
||||
|
||||
### Bug Fixes
|
||||
- **AI Chat**: Fixed document upload failing on filenames with special characters
|
||||
```
|
||||
|
||||
> When a release is triggered, CI automatically stamps the version and date, commits the update, and publishes the content to the GitHub release. You do not need to do this manually.
|
||||
|
||||
---
|
||||
|
||||
## 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 `main` 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).*
|
||||
31
Dockerfile
31
Dockerfile
|
|
@ -1,7 +1,7 @@
|
|||
FROM node:22.16.0-alpine3.22 AS base
|
||||
FROM node:22-slim AS base
|
||||
|
||||
# Install bash & curl for entrypoint script compatibility
|
||||
RUN apk add --no-cache bash curl
|
||||
# Install bash & curl for entrypoint script compatibility, graphicsmagick for pdf2pic, and vips-dev & build-base for sharp
|
||||
RUN apt-get update && apt-get install -y bash curl graphicsmagick libvips-dev build-essential
|
||||
|
||||
# All deps stage
|
||||
FROM base AS deps
|
||||
|
|
@ -24,12 +24,35 @@ RUN node ace build
|
|||
|
||||
# Production stage
|
||||
FROM base
|
||||
ARG VERSION=dev
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
# Labels
|
||||
LABEL org.opencontainers.image.title="Project N.O.M.A.D" \
|
||||
org.opencontainers.image.description="The Project N.O.M.A.D Official Docker image" \
|
||||
org.opencontainers.image.version="${VERSION}" \
|
||||
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||
org.opencontainers.image.revision="${VCS_REF}" \
|
||||
org.opencontainers.image.vendor="Crosstalk Solutions, LLC" \
|
||||
org.opencontainers.image.documentation="https://github.com/CrosstalkSolutions/project-nomad/blob/main/README.md" \
|
||||
org.opencontainers.image.source="https://github.com/CrosstalkSolutions/project-nomad" \
|
||||
org.opencontainers.image.licenses="Apache-2.0"
|
||||
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
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"]
|
||||
100
FAQ.md
Normal file
100
FAQ.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# Frequently Asked Questions (FAQ)
|
||||
|
||||
Find answers to some of the most common questions about Project N.O.M.A.D.
|
||||
|
||||
## Can I customize the port(s) that NOMAD uses?
|
||||
|
||||
Yes, you can customize the ports that NOMAD's core services (Command Center, MySQL, Redis) use. Please refer to the [Advanced Installation](README.md#advanced-installation) section of the README for more details on how to do this.
|
||||
|
||||
Note: As of 3/24/2026, only the core services defined in the `docker-compose.yml` file currently support port customization - the installable applications (e.g. Ollama, Kiwix, etc.) do not yet support this, but we have multiple PR's in the works to add this feature for all installable applications in a future release.
|
||||
|
||||
## Can I customize the storage location for NOMAD's data?
|
||||
|
||||
Yes, you can customize the storage location for NOMAD's content by modifying the `docker-compose.yml` file to adjust the appropriate bind mounts to point to your desired storage location on your host machine. Please refer to the [Advanced Installation](README.md#advanced-installation) section of the README for more details on how to do this.
|
||||
|
||||
## Can I run NOMAD on MAC, WSL2, or a non-Debian-based Distro?
|
||||
|
||||
See [Why does NOMAD require a Debian-based OS?](#why-does-nomad-require-a-debian-based-os)
|
||||
|
||||
## Why does NOMAD require a Debian-based OS?
|
||||
|
||||
Project N.O.M.A.D. is currently designed to run on Debian-based Linux distributions (with Ubuntu being the recommended distro) because our installation scripts and Docker configurations are optimized for this environment. While it's technically possible to run the Docker containers on other operating systems that support Docker, we have not tested or optimized the installation process for non-Debian-based systems, so we cannot guarantee a smooth experience on those platforms at this time.
|
||||
|
||||
Support for other operating systems will come in the future, but because our development resources are limited as a free and open-source project, we needed to prioritize our efforts and focus on a narrower set of supported platforms for the initial release. We chose Debian-based Linux as our starting point because it's widely used, easy to spin up, and provides a stable environment for running Docker containers.
|
||||
|
||||
Community members have provided guides for running N.O.M.A.D. on other platforms (e.g. WSL2, Mac, etc.) in our Discord community and [Github Discussions](https://github.com/Crosstalk-Solutions/project-nomad/discussions), so if you're interested in running N.O.M.A.D. on a non-Debian-based system, we recommend checking there for any available resources or guides. However, keep in mind that if you choose to run N.O.M.A.D. on a non-Debian-based system, you may encounter issues that we won't be able to provide support for, and you may need to have a higher level of technical expertise to troubleshoot and resolve any problems that arise.
|
||||
|
||||
## Can I run NOMAD on a Raspberry Pi or other ARM-based device?
|
||||
Project N.O.M.A.D. is currently designed to run on x86-64 architecture, and we have not yet tested or optimized it for ARM-based devices like the Raspberry Pi (and have not published any official images for ARM architecture).
|
||||
|
||||
Support for ARM-based devices is on our roadmap, but our initial focus was on x86-64 hardware due to its widespread use and compatibility with a wide range of applications.
|
||||
|
||||
Community members have forked and published their own ARM-compatible images and installation guides for running N.O.M.A.D. on Raspberry Pi and other ARM-based devices in our Discord community and [Github Discussions](https://github.com/Crosstalk-Solutions/project-nomad/discussions), but these are not officially supported by the core development team, and we cannot guarantee their functionality or provide support for any issues that arise when using these community-created resources.
|
||||
|
||||
## What are the hardware requirements for running NOMAD?
|
||||
|
||||
Project N.O.M.A.D. itself is quite lightweight and can run on even modest x86-64 hardware, but the tools and resources you choose to install with N.O.M.A.D. will determine the specs required for your unique deployment. Please see the [Hardware Guide](https://www.projectnomad.us/hardware) for detailed build recommendations at various price points.
|
||||
|
||||
## Does NOMAD support languages other than English?
|
||||
|
||||
As of March 2026, Project N.O.M.A.D.'s UI is only available in English, and the majority of the tools and resources available through N.O.M.A.D. are also primarily in English. However, we have multi-language support on our roadmap for a future release, and we are actively working on adding support for additional languages both in the UI and in the available tools/resources. If you're interested in contributing to this effort, please check out our [CONTRIBUTING.md](CONTRIBUTING.md) file for guidelines on how to get involved.
|
||||
|
||||
## What technologies is NOMAD built with?
|
||||
|
||||
Project N.O.M.A.D. is built using a combination of technologies, including:
|
||||
- **Docker:** for containerization of the Command Center and its dependencies
|
||||
- **Node.js & TypeScript:** for the backend of the Command Center, particularly the [AdonisJS](https://adonisjs.com/) framework
|
||||
- **React:** for the frontend of the Command Center, utilizing [Vite](https://vitejs.dev/) and [Inertia.js](https://inertiajs.com/) under the hood
|
||||
- **MySQL:** for the Command Center's database
|
||||
- **Redis:** for various caching, background jobs, "cron" tasks, and other internal processes within the Command Center
|
||||
|
||||
NOMAD makes use of the Docker-outside-of-Docker ("DooD") pattern, which allows the Command Center to manage and orchestrate other Docker containers on the host machine without needing to run Docker itself inside a container. This approach provides better performance and compatibility with a wider range of host environments while still allowing for powerful container management capabilities through the Command Center's UI.
|
||||
|
||||
## Can I run NOMAD if I have existing Docker containers on my machine?
|
||||
Yes, you can safely run Project N.O.M.A.D. on a machine that already has existing Docker containers. NOMAD is designed to coexist with other Docker containers and will not interfere with them as long as there are no port conflicts or resource constraints.
|
||||
|
||||
All of NOMAD's containers are prefixed with `nomad_` in their names, so they can be easily identified and managed separately from any other containers you may have running. Just make sure to review the ports that NOMAD's core services (Command Center, MySQL, Redis) use during installation and adjust them if necessary to avoid conflicts with your existing containers.
|
||||
|
||||
## Why does NOMAD require access to the Docker socket?
|
||||
|
||||
See [What technologies is NOMAD built with?](#what-technologies-is-nomad-built-with)
|
||||
|
||||
## Can I use any AI models?
|
||||
NOMAD by default uses Ollama inside of a docker container to run LLM Models for the AI Assistant. So if you find a model on HuggingFace for example, you won't be able to use that model in NOMAD. The list of available models in the AI Assistant settings (/settings/models) may not show all of the models you are looking for. If you found a model from https://ollama.com/search that you'd like to try and its not in the settings page, you can use a curl command to download the model.
|
||||
`curl -X POST -H "Content-Type: application/json" -d '{"model":"MODEL_NAME_HERE"}' http://localhost:8080/api/ollama/models` replacing MODEL_NAME_HERE with the model name from whats in the ollama website.
|
||||
|
||||
## Do I have to install the AI features in NOMAD?
|
||||
|
||||
No, the AI features in NOMAD (Ollama, Qdrant, custom RAG pipeline, etc.) are all optional and not required to use the core functionality of NOMAD.
|
||||
|
||||
## Is NOMAD actually free? Are there any hidden costs?
|
||||
Yes, Project N.O.M.A.D. is completely free and open-source software licensed under the Apache License 2.0. There are no hidden costs or fees associated with using NOMAD itself, and we don't have any plans to introduce "premium" features or paid tiers.
|
||||
|
||||
Aside from the cost of the hardware you choose to run it on, there are no costs associated with using NOMAD.
|
||||
|
||||
## Do you sell hardware or pre-built devices with NOMAD pre-installed?
|
||||
|
||||
No, we do not sell hardware or pre-built devices with NOMAD pre-installed at this time. Project N.O.M.A.D. is a free and open-source software project, and we provide detailed installation instructions and hardware recommendations for users to set up their own NOMAD instances on compatible hardware of their choice. The tradeoff to this DIY approach is some additional setup time and technical know-how required on the user's end, but it also allows for greater flexibility and customization in terms of hardware selection and configuration to best suit each user's unique needs, budget, and preferences.
|
||||
|
||||
## How quickly are issues resolved when reported?
|
||||
|
||||
We strive to address and resolve issues as quickly as possible, but please keep in mind that Project N.O.M.A.D. is a free and open-source project maintained by a small team of volunteers. We prioritize issues based on their severity, impact on users, and the resources required to resolve them. Critical issues that affect a large number of users are typically addressed more quickly, while less severe issues may take longer to resolve. Aside from the development efforts needed to address the issue, we do our best to conduct thorough testing and validation to ensure that any fix we implement doesn't introduce new issues or regressions, which also adds to the time it takes to resolve an issue.
|
||||
|
||||
We also encourage community involvement in troubleshooting and resolving issues, so if you encounter a problem, please consider checking our Discord community and Github Discussions for potential solutions or workarounds while we work on an official fix.
|
||||
|
||||
## How often are new features added or updates released?
|
||||
|
||||
We aim to release updates and new features on a regular basis, but the exact timing can vary based on the complexity of the features being developed, the resources available to our volunteer development team, and the feedback and needs of our community. We typically release smaller "patch" versions more frequently to address bugs and make minor improvements, while larger feature releases may take more time to develop and test before they're ready for release.
|
||||
|
||||
## I opened a PR to contribute a new feature or fix a bug. How long does it usually take for PRs to be reviewed and merged?
|
||||
We appreciate all contributions to the project and strive to review and merge pull requests (PRs) as quickly as possible. The time it takes for a PR to be reviewed and merged can vary based on several factors, including the complexity of the changes, the current workload of our maintainers, and the need for any additional testing or revisions.
|
||||
|
||||
Because NOMAD is still a young project, some PRs (particularly those for new features) may take longer to review and merge as we prioritize building out the core functionality and ensuring stability before adding new features. However, we do our best to provide timely feedback on all PRs and keep contributors informed about the status of their contributions.
|
||||
|
||||
## I have a question that isn't answered here. Where can I ask for help?
|
||||
|
||||
If you have a question that isn't answered in this FAQ, please feel free to ask for help in our Discord community (https://discord.com/invite/crosstalksolutions) or on our Github Discussions page (https://github.com/Crosstalk-Solutions/project-nomad/discussions).
|
||||
|
||||
## I have a suggestion for a new feature or improvement. How can I share it?
|
||||
|
||||
We welcome and encourage suggestions for new features and improvements! We highly encourage sharing your ideas (or upvoting existing suggestions) on our public roadmap at https://roadmap.projectnomad.us, where we track new feature requests. This is the best way to ensure that your suggestion is seen by the development team and the community, and it also allows other community members to upvote and show support for your idea, which can help prioritize it for future development.
|
||||
190
LICENSE
Normal file
190
LICENSE
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by the Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding any notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2024-2026 Crosstalk Solutions LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
97
README.md
97
README.md
|
|
@ -1,32 +1,64 @@
|
|||
# *NOTE: Project N.O.M.A.D. is still in active development and should not be considered stable!*
|
||||
<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"/>
|
||||
|
||||
# Project N.O.M.A.D.
|
||||
### Node for Offline Media, Archives, and Data
|
||||
|
||||
**Knowledge That Never Goes Offline**
|
||||
|
||||
[](https://www.projectnomad.us)
|
||||
[](https://discord.com/invite/crosstalksolutions)
|
||||
[](https://benchmark.projectnomad.us)
|
||||
|
||||
<div style="width: 100;text-align: center;margin-bottom: 25px;">
|
||||
<img src="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/admin/public/project_nomad_logo.png" width="200" height="200"/>
|
||||
</div>
|
||||
|
||||
# Project N.O.M.A.D. (Node for Offline Media, Archives, and Data)
|
||||
Project N.O.M.A.D., is a self-contained, offline survival computer packed with critical tools, knowledge, and AI to keep you informed and empowered—anytime, anywhere.
|
||||
---
|
||||
|
||||
Project N.O.M.A.D. is a self-contained, offline-first knowledge and education server packed with critical tools, knowledge, and AI to keep you informed and empowered—anytime, anywhere.
|
||||
|
||||
## Installation & Quickstart
|
||||
Project N.O.M.A.D. can be installed on any Debian-based operating system (we recommend Ubuntu). Installation is completely terminal-based, and all tools and resources are designed to be accessed through the browser, so there's no need for a desktop environment if you'd rather setup N.O.M.A.D. as a "server" and access it through other clients.
|
||||
|
||||
*Note: sudo/root privileges are required to run the install script*
|
||||
|
||||
### Quick Install (Debian-based OS Only)
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/install/install_nomad.sh -o install_nomad.sh
|
||||
```
|
||||
|
||||
```bash
|
||||
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!
|
||||
|
||||
## How It Works
|
||||
From a technical standpoint, N.O.M.A.D. is primarily a management UI ("Command Center") and API that orchestrates a goodie basket of containerized offline archive tools and resources such as
|
||||
[Kiwix](https://kiwix.org/), [OpenStreetMap](https://www.openstreetmap.org/), [Ollama](https://ollama.com/), [OpenWebUI](https://openwebui.com/), and more.
|
||||
For a complete step-by-step walkthrough (including Ubuntu installation), see the [Installation Guide](https://www.projectnomad.us/install).
|
||||
|
||||
By abstracting the installation of each of these awesome tools, N.O.M.A.D. makes getting your offline survival computer up and running a breeze! N.O.M.A.D. also includes some additional built-in handy tools, such as a ZIM library managment interface, calculators, and more.
|
||||
### 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/))
|
||||
- **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)
|
||||
- **Data Tools** — encryption, encoding, and analysis via [CyberChef](https://gchq.github.io/CyberChef/)
|
||||
- **Notes** — local note-taking via [FlatNotes](https://github.com/dullage/flatnotes)
|
||||
- **System Benchmark** — hardware scoring with a [community leaderboard](https://benchmark.projectnomad.us)
|
||||
- **Easy Setup Wizard** — guided first-time configuration with curated content collections
|
||||
|
||||
N.O.M.A.D. also includes built-in tools like a Wikipedia content selector, ZIM library manager, and content explorer.
|
||||
|
||||
## What's Included
|
||||
|
||||
| Capability | Powered By | What You Get |
|
||||
|-----------|-----------|-------------|
|
||||
| Information Library | Kiwix | Offline Wikipedia, medical references, survival guides, ebooks |
|
||||
| AI Assistant | Ollama + Qdrant | Built-in chat with document upload and semantic search |
|
||||
| Education Platform | Kolibri | Khan Academy courses, progress tracking, multi-user support |
|
||||
| Offline Maps | ProtoMaps | Downloadable regional maps with search and navigation |
|
||||
| Data Tools | CyberChef | Encryption, encoding, hashing, and data analysis |
|
||||
| Notes | FlatNotes | Local note-taking with markdown support |
|
||||
| System Benchmark | Built-in | Hardware scoring, Builder Tags, and community leaderboard |
|
||||
|
||||
## Device Requirements
|
||||
While many similar offline survival computers are designed to be run on bare-minimum, lightweight hardware, Project N.O.M.A.D. is quite the opposite. To install and run the
|
||||
|
|
@ -48,13 +80,18 @@ To run LLM's and other included AI tools:
|
|||
#### Optimal Specs
|
||||
- Processor: AMD Ryzen 7 or Intel Core i7 or better
|
||||
- RAM: 32 GB system memory
|
||||
- Graphics: NVIDIA RTX 3060 or better (more VRAM = run larger models)
|
||||
- Graphics: NVIDIA RTX 3060 or AMD equivalent or better (more VRAM = run larger models)
|
||||
- Storage: At least 250 GB free disk space (preferably on SSD)
|
||||
- OS: Debian-based (Ubuntu recommended)
|
||||
- Stable internet connection (required during install only)
|
||||
|
||||
**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
|
||||
|
||||
## 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.
|
||||
|
||||
|
|
@ -63,10 +100,24 @@ 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.
|
||||
|
||||
## 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" container
|
||||
uses the same version defined there instead of the version in `admin/package.json` (stays at 0.0.0), as it's the only container derived from the code.
|
||||
**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 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
|
||||
|
||||
Project N.O.M.A.D. is licensed under the [Apache License 2.0](LICENSE).
|
||||
|
||||
## Helper Scripts
|
||||
Once installed, Project N.O.M.A.D. has a few helper scripts should you ever need to troubleshoot issues or perform maintenance that can't be done through the Command Center. All of these scripts are found in Project N.O.M.A.D.'s install directory, `/opt/project-nomad`
|
||||
|
|
@ -81,7 +132,7 @@ sudo bash /opt/project-nomad/start_nomad.sh
|
|||
|
||||
###### Stop Script - Stops all installed project containers
|
||||
```bash
|
||||
sudo bash /opt/project-nomad/start_nomad.sh
|
||||
sudo bash /opt/project-nomad/stop_nomad.sh
|
||||
```
|
||||
###
|
||||
|
||||
|
|
@ -92,9 +143,5 @@ sudo bash /opt/project-nomad/update_nomad.sh
|
|||
|
||||
###### Uninstall Script - Need to start fresh? Use the uninstall script to make your life easy. Note: this cannot be undone!
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/install/uninstall_nomad.sh -o uninstall_nomad.sh
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo bash uninstall_nomad.sh
|
||||
curl -fsSL https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/uninstall_nomad.sh -o uninstall_nomad.sh && sudo bash uninstall_nomad.sh
|
||||
```
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
|
||||
## Docker container
|
||||
```
|
||||
docker run --rm -it -p 8080:8080 jturnercosmistack/projectnomad:admin-latest -e PORT=8080 -e HOST=0.0.0.0 -e APP_KEY=secretlongpasswordsecret -e LOG_LEVEL=debug -e DRIVE_DISK=fs
|
||||
```
|
||||
275
admin/app/controllers/benchmark_controller.ts
Normal file
275
admin/app/controllers/benchmark_controller.ts
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import { inject } from '@adonisjs/core'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import { BenchmarkService } from '#services/benchmark_service'
|
||||
import { runBenchmarkValidator, submitBenchmarkValidator } from '#validators/benchmark'
|
||||
import { RunBenchmarkJob } from '#jobs/run_benchmark_job'
|
||||
import type { BenchmarkType } from '../../types/benchmark.js'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
@inject()
|
||||
export default class BenchmarkController {
|
||||
constructor(private benchmarkService: BenchmarkService) {}
|
||||
|
||||
/**
|
||||
* Start a benchmark run (async via job queue, or sync if specified)
|
||||
*/
|
||||
async run({ request, response }: HttpContext) {
|
||||
const payload = await request.validateUsing(runBenchmarkValidator)
|
||||
const benchmarkType: BenchmarkType = payload.benchmark_type || 'full'
|
||||
const runSync = request.input('sync') === 'true' || request.input('sync') === true
|
||||
|
||||
// Check if a benchmark is already running
|
||||
const status = this.benchmarkService.getStatus()
|
||||
if (status.status !== 'idle') {
|
||||
return response.status(409).send({
|
||||
success: false,
|
||||
error: 'A benchmark is already running',
|
||||
current_benchmark_id: status.benchmarkId,
|
||||
})
|
||||
}
|
||||
|
||||
// Run synchronously if requested (useful for local dev without Redis)
|
||||
if (runSync) {
|
||||
try {
|
||||
let result
|
||||
switch (benchmarkType) {
|
||||
case 'full':
|
||||
result = await this.benchmarkService.runFullBenchmark()
|
||||
break
|
||||
case 'system':
|
||||
result = await this.benchmarkService.runSystemBenchmarks()
|
||||
break
|
||||
case 'ai':
|
||||
result = await this.benchmarkService.runAIBenchmark()
|
||||
break
|
||||
default:
|
||||
result = await this.benchmarkService.runFullBenchmark()
|
||||
}
|
||||
return response.send({
|
||||
success: true,
|
||||
benchmark_id: result.benchmark_id,
|
||||
nomad_score: result.nomad_score,
|
||||
result,
|
||||
})
|
||||
} catch (error) {
|
||||
return response.status(500).send({
|
||||
success: false,
|
||||
error: error.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Generate benchmark ID and dispatch job (async)
|
||||
const benchmarkId = randomUUID()
|
||||
const { job, created } = await RunBenchmarkJob.dispatch({
|
||||
benchmark_id: benchmarkId,
|
||||
benchmark_type: benchmarkType,
|
||||
include_ai: benchmarkType === 'full' || benchmarkType === 'ai',
|
||||
})
|
||||
|
||||
return response.status(201).send({
|
||||
success: true,
|
||||
job_id: job?.id || benchmarkId,
|
||||
benchmark_id: benchmarkId,
|
||||
message: created
|
||||
? `${benchmarkType} benchmark started`
|
||||
: 'Benchmark job already exists',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a system-only benchmark (CPU, memory, disk)
|
||||
*/
|
||||
async runSystem({ response }: HttpContext) {
|
||||
const status = this.benchmarkService.getStatus()
|
||||
if (status.status !== 'idle') {
|
||||
return response.status(409).send({
|
||||
success: false,
|
||||
error: 'A benchmark is already running',
|
||||
})
|
||||
}
|
||||
|
||||
const benchmarkId = randomUUID()
|
||||
await RunBenchmarkJob.dispatch({
|
||||
benchmark_id: benchmarkId,
|
||||
benchmark_type: 'system',
|
||||
include_ai: false,
|
||||
})
|
||||
|
||||
return response.status(201).send({
|
||||
success: true,
|
||||
benchmark_id: benchmarkId,
|
||||
message: 'System benchmark started',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an AI-only benchmark
|
||||
*/
|
||||
async runAI({ response }: HttpContext) {
|
||||
const status = this.benchmarkService.getStatus()
|
||||
if (status.status !== 'idle') {
|
||||
return response.status(409).send({
|
||||
success: false,
|
||||
error: 'A benchmark is already running',
|
||||
})
|
||||
}
|
||||
|
||||
const benchmarkId = randomUUID()
|
||||
await RunBenchmarkJob.dispatch({
|
||||
benchmark_id: benchmarkId,
|
||||
benchmark_type: 'ai',
|
||||
include_ai: true,
|
||||
})
|
||||
|
||||
return response.status(201).send({
|
||||
success: true,
|
||||
benchmark_id: benchmarkId,
|
||||
message: 'AI benchmark started',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all benchmark results
|
||||
*/
|
||||
async results({}: HttpContext) {
|
||||
const results = await this.benchmarkService.getAllResults()
|
||||
return {
|
||||
results,
|
||||
total: results.length,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest benchmark result
|
||||
*/
|
||||
async latest({}: HttpContext) {
|
||||
const result = await this.benchmarkService.getLatestResult()
|
||||
if (!result) {
|
||||
return { result: null }
|
||||
}
|
||||
return { result }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific benchmark result by ID
|
||||
*/
|
||||
async show({ params, response }: HttpContext) {
|
||||
const result = await this.benchmarkService.getResultById(params.id)
|
||||
if (!result) {
|
||||
return response.status(404).send({
|
||||
error: 'Benchmark result not found',
|
||||
})
|
||||
}
|
||||
return { result }
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit benchmark results to central repository
|
||||
*/
|
||||
async submit({ request, response }: HttpContext) {
|
||||
const payload = await request.validateUsing(submitBenchmarkValidator)
|
||||
const anonymous = request.input('anonymous') === true || request.input('anonymous') === 'true'
|
||||
|
||||
try {
|
||||
const submitResult = await this.benchmarkService.submitToRepository(payload.benchmark_id, anonymous)
|
||||
return response.send({
|
||||
success: true,
|
||||
repository_id: submitResult.repository_id,
|
||||
percentile: submitResult.percentile,
|
||||
})
|
||||
} catch (error) {
|
||||
// Pass through the status code from the service if available, otherwise default to 400
|
||||
const statusCode = (error as any).statusCode || 400
|
||||
return response.status(statusCode).send({
|
||||
success: false,
|
||||
error: error.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update builder tag for a benchmark result
|
||||
*/
|
||||
async updateBuilderTag({ request, response }: HttpContext) {
|
||||
const benchmarkId = request.input('benchmark_id')
|
||||
const builderTag = request.input('builder_tag')
|
||||
|
||||
if (!benchmarkId) {
|
||||
return response.status(400).send({
|
||||
success: false,
|
||||
error: 'benchmark_id is required',
|
||||
})
|
||||
}
|
||||
|
||||
const result = await this.benchmarkService.getResultById(benchmarkId)
|
||||
if (!result) {
|
||||
return response.status(404).send({
|
||||
success: false,
|
||||
error: 'Benchmark result not found',
|
||||
})
|
||||
}
|
||||
|
||||
// Validate builder tag format if provided
|
||||
if (builderTag) {
|
||||
const tagPattern = /^[A-Za-z]+-[A-Za-z]+-\d{4}$/
|
||||
if (!tagPattern.test(builderTag)) {
|
||||
return response.status(400).send({
|
||||
success: false,
|
||||
error: 'Invalid builder tag format. Expected: Word-Word-0000',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
result.builder_tag = builderTag || null
|
||||
await result.save()
|
||||
|
||||
return response.send({
|
||||
success: true,
|
||||
builder_tag: result.builder_tag,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comparison stats from central repository
|
||||
*/
|
||||
async comparison({}: HttpContext) {
|
||||
const stats = await this.benchmarkService.getComparisonStats()
|
||||
return { stats }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current benchmark status
|
||||
*/
|
||||
async status({}: HttpContext) {
|
||||
return this.benchmarkService.getStatus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get benchmark settings
|
||||
*/
|
||||
async settings({}: HttpContext) {
|
||||
const { default: BenchmarkSetting } = await import('#models/benchmark_setting')
|
||||
return await BenchmarkSetting.getAllSettings()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update benchmark settings
|
||||
*/
|
||||
async updateSettings({ request, response }: HttpContext) {
|
||||
const { default: BenchmarkSetting } = await import('#models/benchmark_setting')
|
||||
const body = request.body()
|
||||
|
||||
if (body.allow_anonymous_submission !== undefined) {
|
||||
await BenchmarkSetting.setValue(
|
||||
'allow_anonymous_submission',
|
||||
body.allow_anonymous_submission ? 'true' : 'false'
|
||||
)
|
||||
}
|
||||
|
||||
return response.send({
|
||||
success: true,
|
||||
settings: await BenchmarkSetting.getAllSettings(),
|
||||
})
|
||||
}
|
||||
}
|
||||
113
admin/app/controllers/chats_controller.ts
Normal file
113
admin/app/controllers/chats_controller.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { inject } from '@adonisjs/core'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import { ChatService } from '#services/chat_service'
|
||||
import { createSessionSchema, updateSessionSchema, addMessageSchema } from '#validators/chat'
|
||||
import KVStore from '#models/kv_store'
|
||||
import { SystemService } from '#services/system_service'
|
||||
import { SERVICE_NAMES } from '../../constants/service_names.js'
|
||||
|
||||
@inject()
|
||||
export default class ChatsController {
|
||||
constructor(private chatService: ChatService, private systemService: SystemService) {}
|
||||
|
||||
async inertia({ inertia, response }: HttpContext) {
|
||||
const aiAssistantInstalled = await this.systemService.checkServiceInstalled(SERVICE_NAMES.OLLAMA)
|
||||
if (!aiAssistantInstalled) {
|
||||
return response.status(404).json({ error: 'AI Assistant service not installed' })
|
||||
}
|
||||
|
||||
const chatSuggestionsEnabled = await KVStore.getValue('chat.suggestionsEnabled')
|
||||
return inertia.render('chat', {
|
||||
settings: {
|
||||
chatSuggestionsEnabled: chatSuggestionsEnabled ?? false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async index({}: HttpContext) {
|
||||
return await this.chatService.getAllSessions()
|
||||
}
|
||||
|
||||
async show({ params, response }: HttpContext) {
|
||||
const sessionId = parseInt(params.id)
|
||||
const session = await this.chatService.getSession(sessionId)
|
||||
|
||||
if (!session) {
|
||||
return response.status(404).json({ error: 'Session not found' })
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
async store({ request, response }: HttpContext) {
|
||||
try {
|
||||
const data = await request.validateUsing(createSessionSchema)
|
||||
const session = await this.chatService.createSession(data.title, data.model)
|
||||
return response.status(201).json(session)
|
||||
} catch (error) {
|
||||
return response.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to create session',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async suggestions({ response }: HttpContext) {
|
||||
try {
|
||||
const suggestions = await this.chatService.getChatSuggestions()
|
||||
return response.status(200).json({ suggestions })
|
||||
} catch (error) {
|
||||
return response.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to get suggestions',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async update({ params, request, response }: HttpContext) {
|
||||
try {
|
||||
const sessionId = parseInt(params.id)
|
||||
const data = await request.validateUsing(updateSessionSchema)
|
||||
const session = await this.chatService.updateSession(sessionId, data)
|
||||
return session
|
||||
} catch (error) {
|
||||
return response.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to update session',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async destroy({ params, response }: HttpContext) {
|
||||
try {
|
||||
const sessionId = parseInt(params.id)
|
||||
await this.chatService.deleteSession(sessionId)
|
||||
return response.status(204)
|
||||
} catch (error) {
|
||||
return response.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to delete session',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async addMessage({ params, request, response }: HttpContext) {
|
||||
try {
|
||||
const sessionId = parseInt(params.id)
|
||||
const data = await request.validateUsing(addMessageSchema)
|
||||
const message = await this.chatService.addMessage(sessionId, data.role, data.content)
|
||||
return response.status(201).json(message)
|
||||
} catch (error) {
|
||||
return response.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to add message',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async destroyAll({ response }: HttpContext) {
|
||||
try {
|
||||
const result = await this.chatService.deleteAllSessions()
|
||||
return response.status(200).json(result)
|
||||
} catch (error) {
|
||||
return response.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to delete all sessions',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
30
admin/app/controllers/collection_updates_controller.ts
Normal file
30
admin/app/controllers/collection_updates_controller.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { CollectionUpdateService } from '#services/collection_update_service'
|
||||
import {
|
||||
assertNotPrivateUrl,
|
||||
applyContentUpdateValidator,
|
||||
applyAllContentUpdatesValidator,
|
||||
} from '#validators/common'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
|
||||
export default class CollectionUpdatesController {
|
||||
async checkForUpdates({}: HttpContext) {
|
||||
const service = new CollectionUpdateService()
|
||||
return await service.checkForUpdates()
|
||||
}
|
||||
|
||||
async applyUpdate({ request }: HttpContext) {
|
||||
const update = await request.validateUsing(applyContentUpdateValidator)
|
||||
assertNotPrivateUrl(update.download_url)
|
||||
const service = new CollectionUpdateService()
|
||||
return await service.applyUpdate(update)
|
||||
}
|
||||
|
||||
async applyAllUpdates({ request }: HttpContext) {
|
||||
const { updates } = await request.validateUsing(applyAllContentUpdatesValidator)
|
||||
for (const update of updates) {
|
||||
assertNotPrivateUrl(update.download_url)
|
||||
}
|
||||
const service = new CollectionUpdateService()
|
||||
return await service.applyAllUpdates(updates)
|
||||
}
|
||||
}
|
||||
|
|
@ -15,4 +15,9 @@ 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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
import { SystemService } from '#services/system_service'
|
||||
import { ZimService } from '#services/zim_service'
|
||||
import { CollectionManifestService } from '#services/collection_manifest_service'
|
||||
import { inject } from '@adonisjs/core'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
|
||||
@inject()
|
||||
export default class EasySetupController {
|
||||
constructor(private systemService: SystemService) {}
|
||||
constructor(
|
||||
private systemService: SystemService,
|
||||
private zimService: ZimService
|
||||
) {}
|
||||
|
||||
async index({ inertia }: HttpContext) {
|
||||
const services = await this.systemService.getServices({ installedOnly: false })
|
||||
|
|
@ -18,4 +23,26 @@ export default class EasySetupController {
|
|||
async complete({ inertia }: HttpContext) {
|
||||
return inertia.render('easy-setup/complete')
|
||||
}
|
||||
|
||||
async listCuratedCategories({}: HttpContext) {
|
||||
return await this.zimService.listCuratedCategories()
|
||||
}
|
||||
|
||||
async refreshManifests({}: HttpContext) {
|
||||
const manifestService = new CollectionManifestService()
|
||||
const [zimChanged, mapsChanged, wikiChanged] = await Promise.all([
|
||||
manifestService.fetchAndCacheSpec('zim_categories'),
|
||||
manifestService.fetchAndCacheSpec('maps'),
|
||||
manifestService.fetchAndCacheSpec('wikipedia'),
|
||||
])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
changed: {
|
||||
zim_categories: zimChanged,
|
||||
maps: mapsChanged,
|
||||
wikipedia: wikiChanged,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { MapService } from '#services/map_service'
|
||||
import {
|
||||
assertNotPrivateUrl,
|
||||
downloadCollectionValidator,
|
||||
filenameParamValidator,
|
||||
remoteDownloadValidator,
|
||||
|
|
@ -13,7 +14,7 @@ export default class MapsController {
|
|||
constructor(private mapService: MapService) {}
|
||||
|
||||
async index({ inertia }: HttpContext) {
|
||||
const baseAssetsCheck = await this.mapService.checkBaseAssetsExist()
|
||||
const baseAssetsCheck = await this.mapService.ensureBaseAssets()
|
||||
const regionFiles = await this.mapService.listRegions()
|
||||
return inertia.render('maps', {
|
||||
maps: {
|
||||
|
|
@ -23,19 +24,16 @@ export default class MapsController {
|
|||
})
|
||||
}
|
||||
|
||||
async checkBaseAssets({}: HttpContext) {
|
||||
const exists = await this.mapService.checkBaseAssetsExist()
|
||||
return { exists }
|
||||
}
|
||||
|
||||
async downloadBaseAssets({ request }: HttpContext) {
|
||||
const payload = await request.validateUsing(remoteDownloadValidatorOptional)
|
||||
if (payload.url) assertNotPrivateUrl(payload.url)
|
||||
await this.mapService.downloadBaseAssets(payload.url)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
async downloadRemote({ request }: HttpContext) {
|
||||
const payload = await request.validateUsing(remoteDownloadValidator)
|
||||
assertNotPrivateUrl(payload.url)
|
||||
const filename = await this.mapService.downloadRemote(payload.url)
|
||||
return {
|
||||
message: 'Download started successfully',
|
||||
|
|
@ -57,6 +55,7 @@ export default class MapsController {
|
|||
// For providing a "preflight" check in the UI before actually starting a background download
|
||||
async downloadRemotePreflight({ request }: HttpContext) {
|
||||
const payload = await request.validateUsing(remoteDownloadValidator)
|
||||
assertNotPrivateUrl(payload.url)
|
||||
const info = await this.mapService.downloadRemotePreflight(payload.url)
|
||||
return info
|
||||
}
|
||||
|
|
@ -74,8 +73,17 @@ export default class MapsController {
|
|||
return await this.mapService.listRegions()
|
||||
}
|
||||
|
||||
async styles({ response }: HttpContext) {
|
||||
const styles = await this.mapService.generateStylesJSON()
|
||||
async styles({ request, response }: HttpContext) {
|
||||
// Automatically ensure base assets are present before generating styles
|
||||
const baseAssetsExist = await this.mapService.ensureBaseAssets()
|
||||
if (!baseAssetsExist) {
|
||||
return response.status(500).send({
|
||||
message:
|
||||
'Base map assets are missing and could not be downloaded. Please check your connection and try again.',
|
||||
})
|
||||
}
|
||||
|
||||
const styles = await this.mapService.generateStylesJSON(request.host(), request.protocol())
|
||||
return response.json(styles)
|
||||
}
|
||||
|
||||
|
|
|
|||
275
admin/app/controllers/ollama_controller.ts
Normal file
275
admin/app/controllers/ollama_controller.ts
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import { ChatService } from '#services/chat_service'
|
||||
import { OllamaService } from '#services/ollama_service'
|
||||
import { RagService } from '#services/rag_service'
|
||||
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 logger from '@adonisjs/core/services/logger'
|
||||
import type { Message } from 'ollama'
|
||||
|
||||
@inject()
|
||||
export default class OllamaController {
|
||||
constructor(
|
||||
private chatService: ChatService,
|
||||
private ollamaService: OllamaService,
|
||||
private ragService: RagService
|
||||
) { }
|
||||
|
||||
async availableModels({ request }: HttpContext) {
|
||||
const reqData = await request.validateUsing(getAvailableModelsSchema)
|
||||
return await this.ollamaService.getAvailableModels({
|
||||
sort: reqData.sort,
|
||||
recommendedOnly: reqData.recommendedOnly,
|
||||
query: reqData.query || null,
|
||||
limit: reqData.limit || 15,
|
||||
force: reqData.force,
|
||||
})
|
||||
}
|
||||
|
||||
async chat({ request, response }: HttpContext) {
|
||||
const reqData = await request.validateUsing(chatSchema)
|
||||
|
||||
// Flush SSE headers immediately so the client connection is open while
|
||||
// pre-processing (query rewriting, RAG lookup) runs in the background.
|
||||
if (reqData.stream) {
|
||||
response.response.setHeader('Content-Type', 'text/event-stream')
|
||||
response.response.setHeader('Cache-Control', 'no-cache')
|
||||
response.response.setHeader('Connection', 'keep-alive')
|
||||
response.response.flushHeaders()
|
||||
}
|
||||
|
||||
try {
|
||||
// If there are no system messages in the chat inject system prompts
|
||||
const hasSystemMessage = reqData.messages.some((msg) => msg.role === 'system')
|
||||
if (!hasSystemMessage) {
|
||||
const systemPrompt = {
|
||||
role: 'system' as const,
|
||||
content: SYSTEM_PROMPTS.default,
|
||||
}
|
||||
logger.debug('[OllamaController] Injecting system prompt')
|
||||
reqData.messages.unshift(systemPrompt)
|
||||
}
|
||||
|
||||
// Query rewriting for better RAG retrieval with manageable context
|
||||
// Will return user's latest message if no rewriting is needed
|
||||
const rewrittenQuery = await this.rewriteQueryWithContext(reqData.messages)
|
||||
|
||||
logger.debug(`[OllamaController] Rewritten query for RAG: "${rewrittenQuery}"`)
|
||||
if (rewrittenQuery) {
|
||||
const relevantDocs = await this.ragService.searchSimilarDocuments(
|
||||
rewrittenQuery,
|
||||
5, // Top 5 most relevant chunks
|
||||
0.3 // Minimum similarity score of 0.3
|
||||
)
|
||||
|
||||
logger.debug(`[RAG] Retrieved ${relevantDocs.length} relevant documents for query: "${rewrittenQuery}"`)
|
||||
|
||||
// If relevant context is found, inject as a system message with adaptive limits
|
||||
if (relevantDocs.length > 0) {
|
||||
// Determine context budget based on model size
|
||||
const { maxResults, maxTokens } = this.getContextLimitsForModel(reqData.model)
|
||||
let trimmedDocs = relevantDocs.slice(0, maxResults)
|
||||
|
||||
// Apply token cap if set (estimate ~4 chars per token)
|
||||
// Always include the first (most relevant) result — the cap only gates subsequent results
|
||||
if (maxTokens > 0) {
|
||||
const charCap = maxTokens * 4
|
||||
let totalChars = 0
|
||||
trimmedDocs = trimmedDocs.filter((doc, idx) => {
|
||||
totalChars += doc.text.length
|
||||
return idx === 0 || totalChars <= charCap
|
||||
})
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[RAG] Injecting ${trimmedDocs.length}/${relevantDocs.length} results (model: ${reqData.model}, maxResults: ${maxResults}, maxTokens: ${maxTokens || 'unlimited'})`
|
||||
)
|
||||
|
||||
const contextText = trimmedDocs
|
||||
.map((doc, idx) => `[Context ${idx + 1}] (Relevance: ${(doc.score * 100).toFixed(1)}%)\n${doc.text}`)
|
||||
.join('\n\n')
|
||||
|
||||
const systemMessage = {
|
||||
role: 'system' as const,
|
||||
content: SYSTEM_PROMPTS.rag_context(contextText),
|
||||
}
|
||||
|
||||
// Insert system message at the beginning (after any existing system messages)
|
||||
const firstNonSystemIndex = reqData.messages.findIndex((msg) => msg.role !== 'system')
|
||||
const insertIndex = firstNonSystemIndex === -1 ? 0 : firstNonSystemIndex
|
||||
reqData.messages.splice(insertIndex, 0, systemMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
const think: boolean | 'medium' = thinkingCapability ? (reqData.model.startsWith('gpt-oss') ? 'medium' : true) : false
|
||||
|
||||
// Separate sessionId from the Ollama request payload — Ollama rejects unknown fields
|
||||
const { sessionId, ...ollamaRequest } = reqData
|
||||
|
||||
// Save user message to DB before streaming if sessionId provided
|
||||
let userContent: string | null = null
|
||||
if (sessionId) {
|
||||
const lastUserMsg = [...reqData.messages].reverse().find((m) => m.role === 'user')
|
||||
if (lastUserMsg) {
|
||||
userContent = lastUserMsg.content
|
||||
await this.chatService.addMessage(sessionId, 'user', userContent)
|
||||
}
|
||||
}
|
||||
|
||||
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 })
|
||||
let fullContent = ''
|
||||
for await (const chunk of stream) {
|
||||
if (chunk.message?.content) {
|
||||
fullContent += chunk.message.content
|
||||
}
|
||||
response.response.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
||||
}
|
||||
response.response.end()
|
||||
|
||||
// Save assistant message and optionally generate title
|
||||
if (sessionId && fullContent) {
|
||||
await this.chatService.addMessage(sessionId, 'assistant', fullContent)
|
||||
const messageCount = await this.chatService.getMessageCount(sessionId)
|
||||
if (messageCount <= 2 && userContent) {
|
||||
this.chatService.generateTitle(sessionId, userContent, fullContent).catch((err) => {
|
||||
logger.error(`[OllamaController] Title generation failed: ${err instanceof Error ? err.message : err}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Non-streaming (legacy) path
|
||||
const result = await this.ollamaService.chat({ ...ollamaRequest, think })
|
||||
|
||||
if (sessionId && result?.message?.content) {
|
||||
await this.chatService.addMessage(sessionId, 'assistant', result.message.content)
|
||||
const messageCount = await this.chatService.getMessageCount(sessionId)
|
||||
if (messageCount <= 2 && userContent) {
|
||||
this.chatService.generateTitle(sessionId, userContent, result.message.content).catch((err) => {
|
||||
logger.error(`[OllamaController] Title generation failed: ${err instanceof Error ? err.message : err}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
if (reqData.stream) {
|
||||
response.response.write(`data: ${JSON.stringify({ error: true })}\n\n`)
|
||||
response.response.end()
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async deleteModel({ request }: HttpContext) {
|
||||
const reqData = await request.validateUsing(modelNameSchema)
|
||||
await this.ollamaService.deleteModel(reqData.model)
|
||||
return {
|
||||
success: true,
|
||||
message: `Model deleted: ${reqData.model}`,
|
||||
}
|
||||
}
|
||||
|
||||
async dispatchModelDownload({ request }: HttpContext) {
|
||||
const reqData = await request.validateUsing(modelNameSchema)
|
||||
await this.ollamaService.dispatchModelDownload(reqData.model)
|
||||
return {
|
||||
success: true,
|
||||
message: `Download job dispatched for model: ${reqData.model}`,
|
||||
}
|
||||
}
|
||||
|
||||
async installedModels({ }: HttpContext) {
|
||||
return await this.ollamaService.getModels()
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines RAG context limits based on model size extracted from the model name.
|
||||
* Parses size indicators like "1b", "3b", "8b", "70b" from model names/tags.
|
||||
*/
|
||||
private getContextLimitsForModel(modelName: string): { maxResults: number; maxTokens: number } {
|
||||
// Extract parameter count from model name (e.g., "llama3.2:3b", "qwen2.5:1.5b", "gemma:7b")
|
||||
const sizeMatch = modelName.match(/(\d+\.?\d*)[bB]/)
|
||||
const paramBillions = sizeMatch ? parseFloat(sizeMatch[1]) : 8 // default to 8B if unknown
|
||||
|
||||
for (const tier of RAG_CONTEXT_LIMITS) {
|
||||
if (paramBillions <= tier.maxParams) {
|
||||
return { maxResults: tier.maxResults, maxTokens: tier.maxTokens }
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: no limits
|
||||
return { maxResults: 5, maxTokens: 0 }
|
||||
}
|
||||
|
||||
private async rewriteQueryWithContext(
|
||||
messages: Message[]
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
// Get recent conversation history (last 6 messages for 3 turns)
|
||||
const recentMessages = messages.slice(-6)
|
||||
|
||||
// Skip rewriting for short conversations. Rewriting adds latency with
|
||||
// little RAG benefit until there is enough context to matter.
|
||||
const userMessages = recentMessages.filter(msg => msg.role === 'user')
|
||||
if (userMessages.length <= 2) {
|
||||
return userMessages[userMessages.length - 1]?.content || null
|
||||
}
|
||||
|
||||
const conversationContext = recentMessages
|
||||
.map(msg => {
|
||||
const role = msg.role === 'user' ? 'User' : 'Assistant'
|
||||
// Truncate assistant messages to first 200 chars to keep context manageable
|
||||
const content = msg.role === 'assistant'
|
||||
? msg.content.slice(0, 200) + (msg.content.length > 200 ? '...' : '')
|
||||
: msg.content
|
||||
return `${role}: "${content}"`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
const installedModels = await this.ollamaService.getModels(true)
|
||||
const rewriteModelAvailable = installedModels?.some(model => model.name === DEFAULT_QUERY_REWRITE_MODEL)
|
||||
if (!rewriteModelAvailable) {
|
||||
logger.warn(`[RAG] Query rewrite model "${DEFAULT_QUERY_REWRITE_MODEL}" not available. Skipping query rewriting.`)
|
||||
const lastUserMessage = [...messages].reverse().find(msg => msg.role === 'user')
|
||||
return lastUserMessage?.content || null
|
||||
}
|
||||
|
||||
// FUTURE ENHANCEMENT: allow the user to specify which model to use for rewriting
|
||||
const response = await this.ollamaService.chat({
|
||||
model: DEFAULT_QUERY_REWRITE_MODEL,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: SYSTEM_PROMPTS.query_rewrite,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Conversation:\n${conversationContext}\n\nRewritten Query:`,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const rewrittenQuery = response.message.content.trim()
|
||||
logger.info(`[RAG] Query rewritten: "${rewrittenQuery}"`)
|
||||
return rewrittenQuery
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[RAG] Query rewriting failed: ${error instanceof Error ? error.message : error}`
|
||||
)
|
||||
// Fallback to last user message if rewriting fails
|
||||
const lastUserMessage = [...messages].reverse().find(msg => msg.role === 'user')
|
||||
return lastUserMessage?.content || null
|
||||
}
|
||||
}
|
||||
}
|
||||
85
admin/app/controllers/rag_controller.ts
Normal file
85
admin/app/controllers/rag_controller.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { RagService } from '#services/rag_service'
|
||||
import { EmbedFileJob } from '#jobs/embed_file_job'
|
||||
import { inject } from '@adonisjs/core'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import app from '@adonisjs/core/services/app'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { sanitizeFilename } from '../utils/fs.js'
|
||||
import { deleteFileSchema, getJobStatusSchema } from '#validators/rag'
|
||||
|
||||
@inject()
|
||||
export default class RagController {
|
||||
constructor(private ragService: RagService) { }
|
||||
|
||||
public async upload({ request, response }: HttpContext) {
|
||||
const uploadedFile = request.file('file')
|
||||
if (!uploadedFile) {
|
||||
return response.status(400).json({ error: 'No file uploaded' })
|
||||
}
|
||||
|
||||
const randomSuffix = randomBytes(6).toString('hex')
|
||||
const sanitizedName = sanitizeFilename(uploadedFile.clientName)
|
||||
|
||||
const fileName = `${sanitizedName}-${randomSuffix}.${uploadedFile.extname || 'txt'}`
|
||||
const fullPath = app.makePath(RagService.UPLOADS_STORAGE_PATH, fileName)
|
||||
|
||||
await uploadedFile.move(app.makePath(RagService.UPLOADS_STORAGE_PATH), {
|
||||
name: fileName,
|
||||
})
|
||||
|
||||
// Dispatch background job for embedding
|
||||
const result = await EmbedFileJob.dispatch({
|
||||
filePath: fullPath,
|
||||
fileName,
|
||||
})
|
||||
|
||||
return response.status(202).json({
|
||||
message: result.message,
|
||||
jobId: result.jobId,
|
||||
fileName,
|
||||
filePath: `/${RagService.UPLOADS_STORAGE_PATH}/${fileName}`,
|
||||
alreadyProcessing: !result.created,
|
||||
})
|
||||
}
|
||||
|
||||
public async getActiveJobs({ response }: HttpContext) {
|
||||
const jobs = await EmbedFileJob.listActiveJobs()
|
||||
return response.status(200).json(jobs)
|
||||
}
|
||||
|
||||
public async getJobStatus({ request, response }: HttpContext) {
|
||||
const reqData = await request.validateUsing(getJobStatusSchema)
|
||||
|
||||
const fullPath = app.makePath(RagService.UPLOADS_STORAGE_PATH, reqData.filePath)
|
||||
const status = await EmbedFileJob.getStatus(fullPath)
|
||||
|
||||
if (!status.exists) {
|
||||
return response.status(404).json({ error: 'Job not found for this file' })
|
||||
}
|
||||
|
||||
return response.status(200).json(status)
|
||||
}
|
||||
|
||||
public async getStoredFiles({ response }: HttpContext) {
|
||||
const files = await this.ragService.getStoredFiles()
|
||||
return response.status(200).json({ files })
|
||||
}
|
||||
|
||||
public async deleteFile({ request, response }: HttpContext) {
|
||||
const { source } = await request.validateUsing(deleteFileSchema)
|
||||
const result = await this.ragService.deleteFileBySource(source)
|
||||
if (!result.success) {
|
||||
return response.status(500).json({ error: result.message })
|
||||
}
|
||||
return response.status(200).json({ message: result.message })
|
||||
}
|
||||
|
||||
public async scanAndSync({ response }: HttpContext) {
|
||||
try {
|
||||
const syncResult = await this.ragService.scanAndSyncStorage()
|
||||
return response.status(200).json(syncResult)
|
||||
} catch (error) {
|
||||
return response.status(500).json({ error: 'Error scanning and syncing storage', details: error.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,20 @@
|
|||
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 type { HttpContext } from '@adonisjs/core/http'
|
||||
import type { KVStoreKey } from '../../types/kv_store.js';
|
||||
|
||||
@inject()
|
||||
export default class SettingsController {
|
||||
constructor(
|
||||
private systemService: SystemService,
|
||||
private mapService: MapService
|
||||
private mapService: MapService,
|
||||
private benchmarkService: BenchmarkService,
|
||||
private ollamaService: OllamaService
|
||||
) { }
|
||||
|
||||
async system({ inertia }: HttpContext) {
|
||||
|
|
@ -32,8 +39,12 @@ export default class SettingsController {
|
|||
return inertia.render('settings/legal');
|
||||
}
|
||||
|
||||
async support({ inertia }: HttpContext) {
|
||||
return inertia.render('settings/support');
|
||||
}
|
||||
|
||||
async maps({ inertia }: HttpContext) {
|
||||
const baseAssetsCheck = await this.mapService.checkBaseAssetsExist();
|
||||
const baseAssetsCheck = await this.mapService.ensureBaseAssets();
|
||||
const regionFiles = await this.mapService.listRegions();
|
||||
return inertia.render('settings/maps', {
|
||||
maps: {
|
||||
|
|
@ -43,6 +54,23 @@ export default class SettingsController {
|
|||
});
|
||||
}
|
||||
|
||||
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 chatSuggestionsEnabled = await KVStore.getValue('chat.suggestionsEnabled')
|
||||
const aiAssistantCustomName = await KVStore.getValue('ai.assistantCustomName')
|
||||
return inertia.render('settings/models', {
|
||||
models: {
|
||||
availableModels: availableModels?.models || [],
|
||||
installedModels: installedModels || [],
|
||||
settings: {
|
||||
chatSuggestionsEnabled: chatSuggestionsEnabled ?? false,
|
||||
aiAssistantCustomName: aiAssistantCustomName ?? '',
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async update({ inertia }: HttpContext) {
|
||||
const updateInfo = await this.systemService.checkLatestVersion();
|
||||
return inertia.render('settings/update', {
|
||||
|
|
@ -61,4 +89,28 @@ export default class SettingsController {
|
|||
async zimRemote({ inertia }: HttpContext) {
|
||||
return inertia.render('settings/zim/remote-explorer');
|
||||
}
|
||||
|
||||
async benchmark({ inertia }: HttpContext) {
|
||||
const latestResult = await this.benchmarkService.getLatestResult();
|
||||
const status = this.benchmarkService.getStatus();
|
||||
return inertia.render('settings/benchmark', {
|
||||
benchmark: {
|
||||
latestResult,
|
||||
status: status.status,
|
||||
currentBenchmarkId: status.benchmarkId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getSetting({ request, response }: HttpContext) {
|
||||
const key = request.qs().key;
|
||||
const value = await KVStore.getValue(key as KVStoreKey);
|
||||
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' });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import { DockerService } from '#services/docker_service';
|
||||
import { SystemService } from '#services/system_service'
|
||||
import { SystemUpdateService } from '#services/system_update_service'
|
||||
import { affectServiceValidator, installServiceValidator } from '#validators/system';
|
||||
import { ContainerRegistryService } from '#services/container_registry_service'
|
||||
import { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job'
|
||||
import { affectServiceValidator, checkLatestVersionValidator, installServiceValidator, subscribeToReleaseNotesValidator, updateServiceValidator } from '#validators/system';
|
||||
import { inject } from '@adonisjs/core'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
|
||||
|
|
@ -10,7 +12,8 @@ export default class SystemController {
|
|||
constructor(
|
||||
private systemService: SystemService,
|
||||
private dockerService: DockerService,
|
||||
private systemUpdateService: SystemUpdateService
|
||||
private systemUpdateService: SystemUpdateService,
|
||||
private containerRegistryService: ContainerRegistryService
|
||||
) { }
|
||||
|
||||
async getInternetStatus({ }: HttpContext) {
|
||||
|
|
@ -32,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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -46,8 +49,19 @@ export default class SystemController {
|
|||
response.send({ success: result.success, message: result.message });
|
||||
}
|
||||
|
||||
async checkLatestVersion({ }: HttpContext) {
|
||||
return await this.systemService.checkLatestVersion();
|
||||
async checkLatestVersion({ request }: HttpContext) {
|
||||
const payload = await request.validateUsing(checkLatestVersionValidator)
|
||||
return await this.systemService.checkLatestVersion(payload.force);
|
||||
}
|
||||
|
||||
async forceReinstallService({ request, response }: HttpContext) {
|
||||
const payload = await request.validateUsing(installServiceValidator);
|
||||
const result = await this.dockerService.forceReinstall(payload.service_name);
|
||||
if (!result) {
|
||||
response.internalServerError({ error: 'Failed to force reinstall service' });
|
||||
return;
|
||||
}
|
||||
response.send({ success: result.success, message: result.message });
|
||||
}
|
||||
|
||||
async requestSystemUpdate({ response }: HttpContext) {
|
||||
|
|
@ -92,4 +106,76 @@ export default class SystemController {
|
|||
const logs = this.systemUpdateService.getUpdateLogs();
|
||||
response.send({ logs });
|
||||
}
|
||||
|
||||
|
||||
async subscribeToReleaseNotes({ request }: HttpContext) {
|
||||
const reqData = await request.validateUsing(subscribeToReleaseNotesValidator);
|
||||
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' })
|
||||
}
|
||||
|
||||
async getAvailableVersions({ params, response }: HttpContext) {
|
||||
const serviceName = params.name
|
||||
const service = await (await import('#models/service')).default
|
||||
.query()
|
||||
.where('service_name', serviceName)
|
||||
.where('installed', true)
|
||||
.first()
|
||||
|
||||
if (!service) {
|
||||
return response.status(404).send({ error: `Service ${serviceName} not found or not installed` })
|
||||
}
|
||||
|
||||
try {
|
||||
const hostArch = await this.getHostArch()
|
||||
const updates = await this.containerRegistryService.getAvailableUpdates(
|
||||
service.container_image,
|
||||
hostArch,
|
||||
service.source_repo
|
||||
)
|
||||
response.send({ versions: updates })
|
||||
} catch (error) {
|
||||
response.status(500).send({ error: `Failed to fetch versions: ${error.message}` })
|
||||
}
|
||||
}
|
||||
|
||||
async updateService({ request, response }: HttpContext) {
|
||||
const payload = await request.validateUsing(updateServiceValidator)
|
||||
const result = await this.dockerService.updateContainer(
|
||||
payload.service_name,
|
||||
payload.target_version
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
response.send({ success: true, message: result.message })
|
||||
} else {
|
||||
response.status(400).send({ error: result.message })
|
||||
}
|
||||
}
|
||||
|
||||
private async getHostArch(): Promise<string> {
|
||||
try {
|
||||
const info = await this.dockerService.docker.info()
|
||||
const arch = info.Architecture || ''
|
||||
const archMap: Record<string, string> = {
|
||||
x86_64: 'amd64',
|
||||
aarch64: 'arm64',
|
||||
armv7l: 'arm',
|
||||
amd64: 'amd64',
|
||||
arm64: 'arm64',
|
||||
}
|
||||
return archMap[arch] || arch.toLowerCase()
|
||||
} catch {
|
||||
return 'amd64'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
import { ZimService } from '#services/zim_service'
|
||||
import {
|
||||
downloadCollectionValidator,
|
||||
assertNotPrivateUrl,
|
||||
downloadCategoryTierValidator,
|
||||
filenameParamValidator,
|
||||
remoteDownloadValidator,
|
||||
remoteDownloadWithMetadataValidator,
|
||||
selectWikipediaValidator,
|
||||
} from '#validators/common'
|
||||
import { listRemoteZimValidator } from '#validators/zim'
|
||||
import { inject } from '@adonisjs/core'
|
||||
|
|
@ -23,7 +25,8 @@ export default class ZimController {
|
|||
}
|
||||
|
||||
async downloadRemote({ request }: HttpContext) {
|
||||
const payload = await request.validateUsing(remoteDownloadValidator)
|
||||
const payload = await request.validateUsing(remoteDownloadWithMetadataValidator)
|
||||
assertNotPrivateUrl(payload.url)
|
||||
const { filename, jobId } = await this.zimService.downloadRemote(payload.url)
|
||||
|
||||
return {
|
||||
|
|
@ -34,26 +37,25 @@ export default class ZimController {
|
|||
}
|
||||
}
|
||||
|
||||
async downloadCollection({ request }: HttpContext) {
|
||||
const payload = await request.validateUsing(downloadCollectionValidator)
|
||||
const resources = await this.zimService.downloadCollection(payload.slug)
|
||||
async listCuratedCategories({}: HttpContext) {
|
||||
return await this.zimService.listCuratedCategories()
|
||||
}
|
||||
|
||||
async downloadCategoryTier({ request }: HttpContext) {
|
||||
const payload = await request.validateUsing(downloadCategoryTierValidator)
|
||||
const resources = await this.zimService.downloadCategoryTier(
|
||||
payload.categorySlug,
|
||||
payload.tierSlug
|
||||
)
|
||||
|
||||
return {
|
||||
message: 'Download started successfully',
|
||||
slug: payload.slug,
|
||||
categorySlug: payload.categorySlug,
|
||||
tierSlug: payload.tierSlug,
|
||||
resources,
|
||||
}
|
||||
}
|
||||
|
||||
async listCuratedCollections({}: HttpContext) {
|
||||
return this.zimService.listCuratedCollections()
|
||||
}
|
||||
|
||||
async fetchLatestCollections({}: HttpContext) {
|
||||
const success = await this.zimService.fetchLatestCollections()
|
||||
return { success }
|
||||
}
|
||||
|
||||
async delete({ request, response }: HttpContext) {
|
||||
const payload = await request.validateUsing(filenameParamValidator)
|
||||
|
||||
|
|
@ -72,4 +74,15 @@ export default class ZimController {
|
|||
message: 'ZIM file deleted successfully',
|
||||
}
|
||||
}
|
||||
|
||||
// Wikipedia selector endpoints
|
||||
|
||||
async getWikipediaState({}: HttpContext) {
|
||||
return this.zimService.getWikipediaState()
|
||||
}
|
||||
|
||||
async selectWikipedia({ request }: HttpContext) {
|
||||
const payload = await request.validateUsing(selectWikipediaValidator)
|
||||
return this.zimService.selectWikipedia(payload.optionId)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
134
admin/app/jobs/check_service_updates_job.ts
Normal file
134
admin/app/jobs/check_service_updates_job.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { Job } from 'bullmq'
|
||||
import { QueueService } from '#services/queue_service'
|
||||
import { DockerService } from '#services/docker_service'
|
||||
import { ContainerRegistryService } from '#services/container_registry_service'
|
||||
import Service from '#models/service'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import transmit from '@adonisjs/transmit/services/main'
|
||||
import { BROADCAST_CHANNELS } from '../../constants/broadcast.js'
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
export class CheckServiceUpdatesJob {
|
||||
static get queue() {
|
||||
return 'service-updates'
|
||||
}
|
||||
|
||||
static get key() {
|
||||
return 'check-service-updates'
|
||||
}
|
||||
|
||||
async handle(_job: Job) {
|
||||
logger.info('[CheckServiceUpdatesJob] Checking for service updates...')
|
||||
|
||||
const dockerService = new DockerService()
|
||||
const registryService = new ContainerRegistryService()
|
||||
|
||||
// Determine host architecture
|
||||
const hostArch = await this.getHostArch(dockerService)
|
||||
|
||||
const installedServices = await Service.query().where('installed', true)
|
||||
let updatesFound = 0
|
||||
|
||||
for (const service of installedServices) {
|
||||
try {
|
||||
const updates = await registryService.getAvailableUpdates(
|
||||
service.container_image,
|
||||
hostArch,
|
||||
service.source_repo
|
||||
)
|
||||
|
||||
const latestUpdate = updates.length > 0 ? updates[0].tag : null
|
||||
|
||||
service.available_update_version = latestUpdate
|
||||
service.update_checked_at = DateTime.now()
|
||||
await service.save()
|
||||
|
||||
if (latestUpdate) {
|
||||
updatesFound++
|
||||
logger.info(
|
||||
`[CheckServiceUpdatesJob] Update available for ${service.service_name}: ${service.container_image} → ${latestUpdate}`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[CheckServiceUpdatesJob] Failed to check updates for ${service.service_name}: ${error.message}`
|
||||
)
|
||||
// Continue checking other services
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[CheckServiceUpdatesJob] Completed. ${updatesFound} update(s) found for ${installedServices.length} service(s).`
|
||||
)
|
||||
|
||||
// Broadcast completion so the frontend can refresh
|
||||
transmit.broadcast(BROADCAST_CHANNELS.SERVICE_UPDATES, {
|
||||
status: 'completed',
|
||||
updatesFound,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
|
||||
return { updatesFound }
|
||||
}
|
||||
|
||||
private async getHostArch(dockerService: DockerService): Promise<string> {
|
||||
try {
|
||||
const info = await dockerService.docker.info()
|
||||
const arch = info.Architecture || ''
|
||||
|
||||
// Map Docker architecture names to OCI names
|
||||
const archMap: Record<string, string> = {
|
||||
x86_64: 'amd64',
|
||||
aarch64: 'arm64',
|
||||
armv7l: 'arm',
|
||||
amd64: 'amd64',
|
||||
arm64: 'arm64',
|
||||
}
|
||||
|
||||
return archMap[arch] || arch.toLowerCase()
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[CheckServiceUpdatesJob] Could not detect host architecture: ${error.message}. Defaulting to amd64.`
|
||||
)
|
||||
return 'amd64'
|
||||
}
|
||||
}
|
||||
|
||||
static async scheduleNightly() {
|
||||
const queueService = new QueueService()
|
||||
const queue = queueService.getQueue(this.queue)
|
||||
|
||||
await queue.upsertJobScheduler(
|
||||
'nightly-service-update-check',
|
||||
{ pattern: '0 3 * * *' },
|
||||
{
|
||||
name: this.key,
|
||||
opts: {
|
||||
removeOnComplete: { count: 7 },
|
||||
removeOnFail: { count: 5 },
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
logger.info('[CheckServiceUpdatesJob] Service update check scheduled with cron: 0 3 * * *')
|
||||
}
|
||||
|
||||
static async dispatch() {
|
||||
const queueService = new QueueService()
|
||||
const queue = queueService.getQueue(this.queue)
|
||||
|
||||
const job = await queue.add(
|
||||
this.key,
|
||||
{},
|
||||
{
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 60000 },
|
||||
removeOnComplete: { count: 7 },
|
||||
removeOnFail: { count: 5 },
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(`[CheckServiceUpdatesJob] Dispatched ad-hoc service update check job ${job.id}`)
|
||||
return job
|
||||
}
|
||||
}
|
||||
77
admin/app/jobs/check_update_job.ts
Normal file
77
admin/app/jobs/check_update_job.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { Job } from 'bullmq'
|
||||
import { QueueService } from '#services/queue_service'
|
||||
import { DockerService } from '#services/docker_service'
|
||||
import { SystemService } from '#services/system_service'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import KVStore from '#models/kv_store'
|
||||
|
||||
export class CheckUpdateJob {
|
||||
static get queue() {
|
||||
return 'system'
|
||||
}
|
||||
|
||||
static get key() {
|
||||
return 'check-update'
|
||||
}
|
||||
|
||||
async handle(_job: Job) {
|
||||
logger.info('[CheckUpdateJob] Running update check...')
|
||||
|
||||
const dockerService = new DockerService()
|
||||
const systemService = new SystemService(dockerService)
|
||||
|
||||
try {
|
||||
const result = await systemService.checkLatestVersion()
|
||||
|
||||
if (result.updateAvailable) {
|
||||
logger.info(
|
||||
`[CheckUpdateJob] Update available: ${result.currentVersion} → ${result.latestVersion}`
|
||||
)
|
||||
} else {
|
||||
await KVStore.setValue('system.updateAvailable', false)
|
||||
logger.info(
|
||||
`[CheckUpdateJob] System is up to date (${result.currentVersion})`
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error(`[CheckUpdateJob] Update check failed: ${error.message}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
static async scheduleNightly() {
|
||||
const queueService = new QueueService()
|
||||
const queue = queueService.getQueue(this.queue)
|
||||
|
||||
await queue.upsertJobScheduler(
|
||||
'nightly-update-check',
|
||||
{ pattern: '0 2,14 * * *' }, // Every 12 hours at 2am and 2pm
|
||||
{
|
||||
name: this.key,
|
||||
opts: {
|
||||
removeOnComplete: { count: 7 },
|
||||
removeOnFail: { count: 5 },
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
logger.info('[CheckUpdateJob] Update check scheduled with cron: 0 2,14 * * *')
|
||||
}
|
||||
|
||||
static async dispatch() {
|
||||
const queueService = new QueueService()
|
||||
const queue = queueService.getQueue(this.queue)
|
||||
|
||||
const job = await queue.add(this.key, {}, {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 60000 },
|
||||
removeOnComplete: { count: 7 },
|
||||
removeOnFail: { count: 5 },
|
||||
})
|
||||
|
||||
logger.info(`[CheckUpdateJob] Dispatched ad-hoc update check job ${job.id}`)
|
||||
return job
|
||||
}
|
||||
}
|
||||
130
admin/app/jobs/download_model_job.ts
Normal file
130
admin/app/jobs/download_model_job.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { Job, UnrecoverableError } from 'bullmq'
|
||||
import { QueueService } from '#services/queue_service'
|
||||
import { createHash } from 'crypto'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import { OllamaService } from '#services/ollama_service'
|
||||
|
||||
export interface DownloadModelJobParams {
|
||||
modelName: string
|
||||
}
|
||||
|
||||
export class DownloadModelJob {
|
||||
static get queue() {
|
||||
return 'model-downloads'
|
||||
}
|
||||
|
||||
static get key() {
|
||||
return 'download-model'
|
||||
}
|
||||
|
||||
static getJobId(modelName: string): string {
|
||||
return createHash('sha256').update(modelName).digest('hex').slice(0, 16)
|
||||
}
|
||||
|
||||
async handle(job: Job) {
|
||||
const { modelName } = job.data as DownloadModelJobParams
|
||||
|
||||
logger.info(`[DownloadModelJob] Attempting to download model: ${modelName}`)
|
||||
|
||||
const ollamaService = new OllamaService()
|
||||
|
||||
// Even if no models are installed, this should return an empty array if ready
|
||||
const existingModels = await ollamaService.getModels()
|
||||
if (!existingModels) {
|
||||
logger.warn(
|
||||
`[DownloadModelJob] Ollama service not ready yet for model ${modelName}. Will retry...`
|
||||
)
|
||||
throw new Error('Ollama service not ready yet')
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[DownloadModelJob] Ollama service is ready. Initiating download for ${modelName}`
|
||||
)
|
||||
|
||||
// Services are ready, initiate the download with progress tracking
|
||||
const result = await ollamaService.downloadModel(modelName, (progressPercent) => {
|
||||
if (progressPercent) {
|
||||
job.updateProgress(Math.floor(progressPercent))
|
||||
logger.info(
|
||||
`[DownloadModelJob] Model ${modelName}: ${progressPercent}%`
|
||||
)
|
||||
}
|
||||
|
||||
// Store detailed progress in job data for clients to query
|
||||
job.updateData({
|
||||
...job.data,
|
||||
status: 'downloading',
|
||||
progress: progressPercent,
|
||||
progress_timestamp: new Date().toISOString(),
|
||||
})
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
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}`)
|
||||
}
|
||||
|
||||
logger.info(`[DownloadModelJob] Successfully completed download for model ${modelName}`)
|
||||
return {
|
||||
modelName,
|
||||
message: result.message,
|
||||
}
|
||||
}
|
||||
|
||||
static async getByModelName(modelName: string): Promise<Job | undefined> {
|
||||
const queueService = new QueueService()
|
||||
const queue = queueService.getQueue(this.queue)
|
||||
const jobId = this.getJobId(modelName)
|
||||
return await queue.getJob(jobId)
|
||||
}
|
||||
|
||||
static async dispatch(params: DownloadModelJobParams) {
|
||||
const queueService = new QueueService()
|
||||
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,
|
||||
attempts: 40, // Many attempts since services may take considerable time to install
|
||||
backoff: {
|
||||
type: 'fixed',
|
||||
delay: 60000, // Check every 60 seconds
|
||||
},
|
||||
removeOnComplete: false, // Keep for status checking
|
||||
removeOnFail: false, // Keep failed jobs for debugging
|
||||
})
|
||||
|
||||
return {
|
||||
job,
|
||||
created: true,
|
||||
message: `Dispatched model download job for ${params.modelName}`,
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message.includes('job already exists')) {
|
||||
const active = await queue.getJob(jobId)
|
||||
return {
|
||||
job: active,
|
||||
created: false,
|
||||
message: `Job already exists for model ${params.modelName}`,
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
259
admin/app/jobs/embed_file_job.ts
Normal file
259
admin/app/jobs/embed_file_job.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
import { Job, UnrecoverableError } from 'bullmq'
|
||||
import { QueueService } from '#services/queue_service'
|
||||
import { EmbedJobWithProgress } from '../../types/rag.js'
|
||||
import { RagService } from '#services/rag_service'
|
||||
import { DockerService } from '#services/docker_service'
|
||||
import { OllamaService } from '#services/ollama_service'
|
||||
import { createHash } from 'crypto'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
|
||||
export interface EmbedFileJobParams {
|
||||
filePath: string
|
||||
fileName: string
|
||||
fileSize?: number
|
||||
// Batch processing for large ZIM files
|
||||
batchOffset?: number // Current batch offset (for ZIM files)
|
||||
totalArticles?: number // Total articles in ZIM (for progress tracking)
|
||||
isFinalBatch?: boolean // Whether this is the last batch (prevents premature deletion)
|
||||
}
|
||||
|
||||
export class EmbedFileJob {
|
||||
static get queue() {
|
||||
return 'file-embeddings'
|
||||
}
|
||||
|
||||
static get key() {
|
||||
return 'embed-file'
|
||||
}
|
||||
|
||||
static getJobId(filePath: string): string {
|
||||
return createHash('sha256').update(filePath).digest('hex').slice(0, 16)
|
||||
}
|
||||
|
||||
async handle(job: Job) {
|
||||
const { filePath, fileName, batchOffset, totalArticles } = job.data as EmbedFileJobParams
|
||||
|
||||
const isZimBatch = batchOffset !== undefined
|
||||
const batchInfo = isZimBatch ? ` (batch offset: ${batchOffset})` : ''
|
||||
logger.info(`[EmbedFileJob] Starting embedding process for: ${fileName}${batchInfo}`)
|
||||
|
||||
const dockerService = new DockerService()
|
||||
const ollamaService = new OllamaService()
|
||||
const ragService = new RagService(dockerService, ollamaService)
|
||||
|
||||
try {
|
||||
// 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...')
|
||||
throw new Error('Ollama service not ready yet')
|
||||
}
|
||||
|
||||
const qdrantUrl = await dockerService.getServiceURL('nomad_qdrant')
|
||||
if (!qdrantUrl) {
|
||||
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 job.updateData({
|
||||
...job.data,
|
||||
status: 'processing',
|
||||
startedAt: job.data.startedAt || Date.now(),
|
||||
})
|
||||
|
||||
logger.info(`[EmbedFileJob] Processing file: ${filePath}`)
|
||||
|
||||
// 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)))
|
||||
}
|
||||
|
||||
// Process and embed the file
|
||||
// Only allow deletion if explicitly marked as final batch
|
||||
const allowDeletion = job.data.isFinalBatch === true
|
||||
const result = await ragService.processAndEmbedFile(
|
||||
filePath,
|
||||
allowDeletion,
|
||||
batchOffset,
|
||||
onProgress
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
logger.error(`[EmbedFileJob] Failed to process file ${fileName}: ${result.message}`)
|
||||
throw new Error(result.message)
|
||||
}
|
||||
|
||||
// For ZIM files with batching, check if more batches are needed
|
||||
if (result.hasMoreBatches) {
|
||||
const nextOffset = (batchOffset || 0) + (result.articlesProcessed || 0)
|
||||
logger.info(
|
||||
`[EmbedFileJob] Batch complete. Dispatching next batch at offset ${nextOffset}`
|
||||
)
|
||||
|
||||
// Dispatch next batch (not final yet)
|
||||
await EmbedFileJob.dispatch({
|
||||
filePath,
|
||||
fileName,
|
||||
batchOffset: nextOffset,
|
||||
totalArticles: totalArticles || result.totalArticles,
|
||||
isFinalBatch: false, // Explicitly not final
|
||||
})
|
||||
|
||||
// Calculate progress based on articles processed
|
||||
const progress = totalArticles
|
||||
? Math.round((nextOffset / totalArticles) * 100)
|
||||
: 50
|
||||
|
||||
await job.updateProgress(progress)
|
||||
await job.updateData({
|
||||
...job.data,
|
||||
status: 'batch_completed',
|
||||
lastBatchAt: Date.now(),
|
||||
chunks: (job.data.chunks || 0) + (result.chunks || 0),
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
fileName,
|
||||
filePath,
|
||||
chunks: result.chunks,
|
||||
hasMoreBatches: true,
|
||||
nextOffset,
|
||||
message: `Batch embedded ${result.chunks} chunks, next batch queued`,
|
||||
}
|
||||
}
|
||||
|
||||
// Final batch or non-batched file - mark as complete
|
||||
const totalChunks = (job.data.chunks || 0) + (result.chunks || 0)
|
||||
await job.updateProgress(100)
|
||||
await job.updateData({
|
||||
...job.data,
|
||||
status: 'completed',
|
||||
completedAt: Date.now(),
|
||||
chunks: totalChunks,
|
||||
})
|
||||
|
||||
const batchMsg = isZimBatch ? ` (final batch, total chunks: ${totalChunks})` : ''
|
||||
logger.info(
|
||||
`[EmbedFileJob] Successfully embedded ${result.chunks} chunks from file: ${fileName}${batchMsg}`
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
fileName,
|
||||
filePath,
|
||||
chunks: result.chunks,
|
||||
message: `Successfully embedded ${result.chunks} chunks`,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[EmbedFileJob] Error embedding file ${fileName}:`, error)
|
||||
|
||||
await job.updateData({
|
||||
...job.data,
|
||||
status: 'failed',
|
||||
failedAt: Date.now(),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
static async listActiveJobs(): Promise<EmbedJobWithProgress[]> {
|
||||
const queueService = new QueueService()
|
||||
const queue = queueService.getQueue(this.queue)
|
||||
const jobs = await queue.getJobs(['waiting', 'active', 'delayed'])
|
||||
|
||||
return jobs.map((job) => ({
|
||||
jobId: job.id!.toString(),
|
||||
fileName: (job.data as EmbedFileJobParams).fileName,
|
||||
filePath: (job.data as EmbedFileJobParams).filePath,
|
||||
progress: typeof job.progress === 'number' ? job.progress : 0,
|
||||
status: ((job.data as any).status as string) ?? 'waiting',
|
||||
}))
|
||||
}
|
||||
|
||||
static async getByFilePath(filePath: string): Promise<Job | undefined> {
|
||||
const queueService = new QueueService()
|
||||
const queue = queueService.getQueue(this.queue)
|
||||
const jobId = this.getJobId(filePath)
|
||||
return await queue.getJob(jobId)
|
||||
}
|
||||
|
||||
static async dispatch(params: EmbedFileJobParams) {
|
||||
const queueService = new QueueService()
|
||||
const queue = queueService.getQueue(this.queue)
|
||||
const jobId = this.getJobId(params.filePath)
|
||||
|
||||
try {
|
||||
const job = await queue.add(this.key, params, {
|
||||
jobId,
|
||||
attempts: 30,
|
||||
backoff: {
|
||||
type: 'fixed',
|
||||
delay: 60000, // Check every 60 seconds for service readiness
|
||||
},
|
||||
removeOnComplete: { count: 50 }, // Keep last 50 completed jobs for history
|
||||
removeOnFail: { count: 20 } // Keep last 20 failed jobs for debugging
|
||||
})
|
||||
|
||||
logger.info(`[EmbedFileJob] Dispatched embedding job for file: ${params.fileName}`)
|
||||
|
||||
return {
|
||||
job,
|
||||
created: true,
|
||||
jobId,
|
||||
message: `File queued for embedding: ${params.fileName}`,
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message && error.message.includes('job already exists')) {
|
||||
const existing = await queue.getJob(jobId)
|
||||
logger.info(`[EmbedFileJob] Job already exists for file: ${params.fileName}`)
|
||||
return {
|
||||
job: existing,
|
||||
created: false,
|
||||
jobId,
|
||||
message: `Embedding job already exists for: ${params.fileName}`,
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
static async getStatus(filePath: string): Promise<{
|
||||
exists: boolean
|
||||
status?: string
|
||||
progress?: number
|
||||
chunks?: number
|
||||
error?: string
|
||||
}> {
|
||||
const job = await this.getByFilePath(filePath)
|
||||
|
||||
if (!job) {
|
||||
return { exists: false }
|
||||
}
|
||||
|
||||
const state = await job.getState()
|
||||
const data = job.data
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
status: data.status || state,
|
||||
progress: typeof job.progress === 'number' ? job.progress : undefined,
|
||||
chunks: data.chunks,
|
||||
error: data.error,
|
||||
}
|
||||
}
|
||||
}
|
||||
101
admin/app/jobs/run_benchmark_job.ts
Normal file
101
admin/app/jobs/run_benchmark_job.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { Job } from 'bullmq'
|
||||
import { QueueService } from '#services/queue_service'
|
||||
import { BenchmarkService } from '#services/benchmark_service'
|
||||
import type { RunBenchmarkJobParams } from '../../types/benchmark.js'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import { DockerService } from '#services/docker_service'
|
||||
|
||||
export class RunBenchmarkJob {
|
||||
static get queue() {
|
||||
return 'benchmarks'
|
||||
}
|
||||
|
||||
static get key() {
|
||||
return 'run-benchmark'
|
||||
}
|
||||
|
||||
async handle(job: Job) {
|
||||
const { benchmark_id, benchmark_type } = job.data as RunBenchmarkJobParams
|
||||
|
||||
logger.info(`[RunBenchmarkJob] Starting benchmark ${benchmark_id} of type ${benchmark_type}`)
|
||||
|
||||
const dockerService = new DockerService()
|
||||
const benchmarkService = new BenchmarkService(dockerService)
|
||||
|
||||
try {
|
||||
let result
|
||||
|
||||
switch (benchmark_type) {
|
||||
case 'full':
|
||||
result = await benchmarkService.runFullBenchmark()
|
||||
break
|
||||
case 'system':
|
||||
result = await benchmarkService.runSystemBenchmarks()
|
||||
break
|
||||
case 'ai':
|
||||
result = await benchmarkService.runAIBenchmark()
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unknown benchmark type: ${benchmark_type}`)
|
||||
}
|
||||
|
||||
logger.info(`[RunBenchmarkJob] Benchmark ${benchmark_id} completed with NOMAD score: ${result.nomad_score}`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
benchmark_id: result.benchmark_id,
|
||||
nomad_score: result.nomad_score,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[RunBenchmarkJob] Benchmark ${benchmark_id} failed: ${error.message}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
static async dispatch(params: RunBenchmarkJobParams) {
|
||||
const queueService = new QueueService()
|
||||
const queue = queueService.getQueue(this.queue)
|
||||
|
||||
try {
|
||||
const job = await queue.add(this.key, params, {
|
||||
jobId: params.benchmark_id,
|
||||
attempts: 1, // Benchmarks shouldn't be retried automatically
|
||||
removeOnComplete: {
|
||||
count: 10, // Keep last 10 completed jobs
|
||||
},
|
||||
removeOnFail: {
|
||||
count: 5, // Keep last 5 failed jobs
|
||||
},
|
||||
})
|
||||
|
||||
logger.info(`[RunBenchmarkJob] Dispatched benchmark job ${params.benchmark_id}`)
|
||||
|
||||
return {
|
||||
job,
|
||||
created: true,
|
||||
message: `Benchmark job ${params.benchmark_id} dispatched successfully`,
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message.includes('job already exists')) {
|
||||
const existing = await queue.getJob(params.benchmark_id)
|
||||
return {
|
||||
job: existing,
|
||||
created: false,
|
||||
message: `Benchmark job ${params.benchmark_id} already exists`,
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
static async getJob(benchmarkId: string): Promise<Job | undefined> {
|
||||
const queueService = new QueueService()
|
||||
const queue = queueService.getQueue(this.queue)
|
||||
return await queue.getJob(benchmarkId)
|
||||
}
|
||||
|
||||
static async getJobState(benchmarkId: string): Promise<string | undefined> {
|
||||
const job = await this.getJob(benchmarkId)
|
||||
return job ? await job.getState() : undefined
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { createHash } from 'crypto'
|
|||
import { DockerService } from '#services/docker_service'
|
||||
import { ZimService } from '#services/zim_service'
|
||||
import { MapService } from '#services/map_service'
|
||||
import { EmbedFileJob } from './embed_file_job.js'
|
||||
|
||||
export class RunDownloadJob {
|
||||
static get queue() {
|
||||
|
|
@ -21,20 +22,9 @@ export class RunDownloadJob {
|
|||
}
|
||||
|
||||
async handle(job: Job) {
|
||||
const { url, filepath, timeout, allowedMimeTypes, forceNew, filetype } =
|
||||
const { url, filepath, timeout, allowedMimeTypes, forceNew, filetype, resourceMetadata } =
|
||||
job.data as RunDownloadJobParams
|
||||
|
||||
// console.log("Simulating delay for job for URL:", url)
|
||||
// await new Promise((resolve) => setTimeout(resolve, 30000)) // Simulate initial delay
|
||||
// console.log("Starting download for URL:", url)
|
||||
|
||||
// // simulate progress updates for demonstration
|
||||
// for (let progress = 0; progress <= 100; progress += 10) {
|
||||
// await new Promise((resolve) => setTimeout(resolve, 20000)) // Simulate time taken for each progress step
|
||||
// job.updateProgress(progress)
|
||||
// console.log(`Job progress for URL ${url}: ${progress}%`)
|
||||
// }
|
||||
|
||||
await doResumableDownload({
|
||||
url,
|
||||
filepath,
|
||||
|
|
@ -47,17 +37,70 @@ export class RunDownloadJob {
|
|||
},
|
||||
async onComplete(url) {
|
||||
try {
|
||||
// Create InstalledResource entry if metadata was provided
|
||||
if (resourceMetadata) {
|
||||
const { default: InstalledResource } = await import('#models/installed_resource')
|
||||
const { DateTime } = await import('luxon')
|
||||
const { getFileStatsIfExists, deleteFileIfExists } = await import('../utils/fs.js')
|
||||
const stats = await getFileStatsIfExists(filepath)
|
||||
|
||||
// Look up the old entry so we can clean up the previous file after updating
|
||||
const oldEntry = await InstalledResource.query()
|
||||
.where('resource_id', resourceMetadata.resource_id)
|
||||
.where('resource_type', filetype as 'zim' | 'map')
|
||||
.first()
|
||||
const oldFilePath = oldEntry?.file_path ?? null
|
||||
|
||||
await InstalledResource.updateOrCreate(
|
||||
{ resource_id: resourceMetadata.resource_id, resource_type: filetype as 'zim' | 'map' },
|
||||
{
|
||||
version: resourceMetadata.version,
|
||||
collection_ref: resourceMetadata.collection_ref,
|
||||
url: url,
|
||||
file_path: filepath,
|
||||
file_size_bytes: stats ? Number(stats.size) : null,
|
||||
installed_at: DateTime.now(),
|
||||
}
|
||||
)
|
||||
|
||||
// Delete the old file if it differs from the new one
|
||||
if (oldFilePath && oldFilePath !== filepath) {
|
||||
try {
|
||||
await deleteFileIfExists(oldFilePath)
|
||||
console.log(`[RunDownloadJob] Deleted old file: ${oldFilePath}`)
|
||||
} catch (deleteError) {
|
||||
console.warn(
|
||||
`[RunDownloadJob] Failed to delete old file ${oldFilePath}:`,
|
||||
deleteError
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filetype === 'zim') {
|
||||
const dockerService = new DockerService()
|
||||
const zimService = new ZimService(dockerService)
|
||||
await zimService.downloadRemoteSuccessCallback([url], true)
|
||||
|
||||
// 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() || '',
|
||||
filePath: filepath,
|
||||
})
|
||||
} 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)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[RunDownloadJob] Error in ZIM download success callback for URL ${url}:`,
|
||||
`[RunDownloadJob] Error in download success callback for URL ${url}:`,
|
||||
error
|
||||
)
|
||||
}
|
||||
|
|
|
|||
85
admin/app/models/benchmark_result.ts
Normal file
85
admin/app/models/benchmark_result.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { DateTime } from 'luxon'
|
||||
import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
|
||||
import type { BenchmarkType, DiskType } from '../../types/benchmark.js'
|
||||
|
||||
export default class BenchmarkResult extends BaseModel {
|
||||
static namingStrategy = new SnakeCaseNamingStrategy()
|
||||
|
||||
@column({ isPrimary: true })
|
||||
declare id: number
|
||||
|
||||
@column()
|
||||
declare benchmark_id: string
|
||||
|
||||
@column()
|
||||
declare benchmark_type: BenchmarkType
|
||||
|
||||
// Hardware information
|
||||
@column()
|
||||
declare cpu_model: string
|
||||
|
||||
@column()
|
||||
declare cpu_cores: number
|
||||
|
||||
@column()
|
||||
declare cpu_threads: number
|
||||
|
||||
@column()
|
||||
declare ram_bytes: number
|
||||
|
||||
@column()
|
||||
declare disk_type: DiskType
|
||||
|
||||
@column()
|
||||
declare gpu_model: string | null
|
||||
|
||||
// System benchmark scores
|
||||
@column()
|
||||
declare cpu_score: number
|
||||
|
||||
@column()
|
||||
declare memory_score: number
|
||||
|
||||
@column()
|
||||
declare disk_read_score: number
|
||||
|
||||
@column()
|
||||
declare disk_write_score: number
|
||||
|
||||
// AI benchmark scores (nullable for system-only benchmarks)
|
||||
@column()
|
||||
declare ai_tokens_per_second: number | null
|
||||
|
||||
@column()
|
||||
declare ai_model_used: string | null
|
||||
|
||||
@column()
|
||||
declare ai_time_to_first_token: number | null
|
||||
|
||||
// Composite NOMAD score (0-100)
|
||||
@column()
|
||||
declare nomad_score: number
|
||||
|
||||
// Repository submission tracking
|
||||
@column({
|
||||
serialize(value) {
|
||||
return Boolean(value)
|
||||
},
|
||||
})
|
||||
declare submitted_to_repository: boolean
|
||||
|
||||
@column.dateTime()
|
||||
declare submitted_at: DateTime | null
|
||||
|
||||
@column()
|
||||
declare repository_id: string | null
|
||||
|
||||
@column()
|
||||
declare builder_tag: string | null
|
||||
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare created_at: DateTime
|
||||
|
||||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||
declare updated_at: DateTime
|
||||
}
|
||||
60
admin/app/models/benchmark_setting.ts
Normal file
60
admin/app/models/benchmark_setting.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { DateTime } from 'luxon'
|
||||
import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
|
||||
import type { BenchmarkSettingKey } from '../../types/benchmark.js'
|
||||
|
||||
export default class BenchmarkSetting extends BaseModel {
|
||||
static namingStrategy = new SnakeCaseNamingStrategy()
|
||||
|
||||
@column({ isPrimary: true })
|
||||
declare id: number
|
||||
|
||||
@column()
|
||||
declare key: BenchmarkSettingKey
|
||||
|
||||
@column()
|
||||
declare value: string | null
|
||||
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare created_at: DateTime
|
||||
|
||||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||
declare updated_at: DateTime
|
||||
|
||||
/**
|
||||
* Get a setting value by key
|
||||
*/
|
||||
static async getValue(key: BenchmarkSettingKey): Promise<string | null> {
|
||||
const setting = await this.findBy('key', key)
|
||||
return setting?.value ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a setting value by key (creates if not exists)
|
||||
*/
|
||||
static async setValue(key: BenchmarkSettingKey, value: string | null): Promise<BenchmarkSetting> {
|
||||
const setting = await this.firstOrCreate({ key }, { key, value })
|
||||
if (setting.value !== value) {
|
||||
setting.value = value
|
||||
await setting.save()
|
||||
}
|
||||
return setting
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all benchmark settings as a typed object
|
||||
*/
|
||||
static async getAllSettings(): Promise<{
|
||||
allow_anonymous_submission: boolean
|
||||
installation_id: string | null
|
||||
last_benchmark_run: string | null
|
||||
}> {
|
||||
const settings = await this.all()
|
||||
const map = new Map(settings.map((s) => [s.key, s.value]))
|
||||
|
||||
return {
|
||||
allow_anonymous_submission: map.get('allow_anonymous_submission') === 'true',
|
||||
installation_id: map.get('installation_id') ?? null,
|
||||
last_benchmark_run: map.get('last_benchmark_run') ?? null,
|
||||
}
|
||||
}
|
||||
}
|
||||
29
admin/app/models/chat_message.ts
Normal file
29
admin/app/models/chat_message.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { DateTime } from 'luxon'
|
||||
import { BaseModel, column, belongsTo, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
|
||||
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
|
||||
import ChatSession from './chat_session.js'
|
||||
|
||||
export default class ChatMessage extends BaseModel {
|
||||
static namingStrategy = new SnakeCaseNamingStrategy()
|
||||
|
||||
@column({ isPrimary: true })
|
||||
declare id: number
|
||||
|
||||
@column()
|
||||
declare session_id: number
|
||||
|
||||
@column()
|
||||
declare role: 'system' | 'user' | 'assistant'
|
||||
|
||||
@column()
|
||||
declare content: string
|
||||
|
||||
@belongsTo(() => ChatSession, { foreignKey: 'id', localKey: 'session_id' })
|
||||
declare session: BelongsTo<typeof ChatSession>
|
||||
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare created_at: DateTime
|
||||
|
||||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||
declare updated_at: DateTime
|
||||
}
|
||||
29
admin/app/models/chat_session.ts
Normal file
29
admin/app/models/chat_session.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { DateTime } from 'luxon'
|
||||
import { BaseModel, column, hasMany, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
|
||||
import type { HasMany } from '@adonisjs/lucid/types/relations'
|
||||
import ChatMessage from './chat_message.js'
|
||||
|
||||
export default class ChatSession extends BaseModel {
|
||||
static namingStrategy = new SnakeCaseNamingStrategy()
|
||||
|
||||
@column({ isPrimary: true })
|
||||
declare id: number
|
||||
|
||||
@column()
|
||||
declare title: string
|
||||
|
||||
@column()
|
||||
declare model: string | null
|
||||
|
||||
@hasMany(() => ChatMessage, {
|
||||
foreignKey: 'session_id',
|
||||
localKey: 'id',
|
||||
})
|
||||
declare messages: HasMany<typeof ChatMessage>
|
||||
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare created_at: DateTime
|
||||
|
||||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||
declare updated_at: DateTime
|
||||
}
|
||||
22
admin/app/models/collection_manifest.ts
Normal file
22
admin/app/models/collection_manifest.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { DateTime } from 'luxon'
|
||||
import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
|
||||
import type { ManifestType } from '../../types/collections.js'
|
||||
|
||||
export default class CollectionManifest extends BaseModel {
|
||||
static namingStrategy = new SnakeCaseNamingStrategy()
|
||||
|
||||
@column({ isPrimary: true })
|
||||
declare type: ManifestType
|
||||
|
||||
@column()
|
||||
declare spec_version: string
|
||||
|
||||
@column({
|
||||
consume: (value: string) => (typeof value === 'string' ? JSON.parse(value) : value),
|
||||
prepare: (value: any) => JSON.stringify(value),
|
||||
})
|
||||
declare spec_data: any
|
||||
|
||||
@column.dateTime()
|
||||
declare fetched_at: DateTime
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import { DateTime } from 'luxon'
|
||||
import { BaseModel, column, hasMany, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
|
||||
import CuratedCollectionResource from './curated_collection_resource.js'
|
||||
import type { HasMany } from '@adonisjs/lucid/types/relations'
|
||||
import type { CuratedCollectionType } from '../../types/curated_collections.js'
|
||||
|
||||
export default class CuratedCollection extends BaseModel {
|
||||
static namingStrategy = new SnakeCaseNamingStrategy()
|
||||
|
||||
@column({ isPrimary: true })
|
||||
declare slug: string
|
||||
|
||||
@column()
|
||||
declare type: CuratedCollectionType
|
||||
|
||||
@column()
|
||||
declare name: string
|
||||
|
||||
@column()
|
||||
declare description: string
|
||||
|
||||
@column()
|
||||
declare icon: string
|
||||
|
||||
@column()
|
||||
declare language: string
|
||||
|
||||
@hasMany(() => CuratedCollectionResource, {
|
||||
foreignKey: 'curated_collection_slug',
|
||||
localKey: 'slug',
|
||||
})
|
||||
declare resources: HasMany<typeof CuratedCollectionResource>
|
||||
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare created_at: DateTime
|
||||
|
||||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||
declare updated_at: DateTime
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import { DateTime } from 'luxon'
|
||||
import { BaseModel, belongsTo, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
|
||||
import CuratedCollection from './curated_collection.js'
|
||||
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
|
||||
|
||||
export default class CuratedCollectionResource extends BaseModel {
|
||||
static namingStrategy = new SnakeCaseNamingStrategy()
|
||||
|
||||
@column({ isPrimary: true })
|
||||
declare id: number
|
||||
|
||||
@column()
|
||||
declare curated_collection_slug: string
|
||||
|
||||
@belongsTo(() => CuratedCollection, {
|
||||
foreignKey: 'slug',
|
||||
localKey: 'curated_collection_slug',
|
||||
})
|
||||
declare curated_collection: BelongsTo<typeof CuratedCollection>
|
||||
|
||||
@column()
|
||||
declare title: string
|
||||
|
||||
@column()
|
||||
declare url: string
|
||||
|
||||
@column()
|
||||
declare description: string
|
||||
|
||||
@column()
|
||||
declare size_mb: number
|
||||
|
||||
@column()
|
||||
declare downloaded: boolean
|
||||
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare created_at: DateTime
|
||||
|
||||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||
declare updated_at: DateTime
|
||||
}
|
||||
33
admin/app/models/installed_resource.ts
Normal file
33
admin/app/models/installed_resource.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { DateTime } from 'luxon'
|
||||
import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
|
||||
|
||||
export default class InstalledResource extends BaseModel {
|
||||
static namingStrategy = new SnakeCaseNamingStrategy()
|
||||
|
||||
@column({ isPrimary: true })
|
||||
declare id: number
|
||||
|
||||
@column()
|
||||
declare resource_id: string
|
||||
|
||||
@column()
|
||||
declare resource_type: 'zim' | 'map'
|
||||
|
||||
@column()
|
||||
declare collection_ref: string | null
|
||||
|
||||
@column()
|
||||
declare version: string
|
||||
|
||||
@column()
|
||||
declare url: string
|
||||
|
||||
@column()
|
||||
declare file_path: string
|
||||
|
||||
@column()
|
||||
declare file_size_bytes: number | null
|
||||
|
||||
@column.dateTime()
|
||||
declare installed_at: DateTime
|
||||
}
|
||||
64
admin/app/models/kv_store.ts
Normal file
64
admin/app/models/kv_store.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { DateTime } from 'luxon'
|
||||
import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
|
||||
import { KV_STORE_SCHEMA, type KVStoreKey, type KVStoreValue } from '../../types/kv_store.js'
|
||||
import { parseBoolean } from '../utils/misc.js'
|
||||
|
||||
/**
|
||||
* Generic key-value store model for storing various settings
|
||||
* that don't necessitate their own dedicated models.
|
||||
*/
|
||||
export default class KVStore extends BaseModel {
|
||||
static table = 'kv_store'
|
||||
static namingStrategy = new SnakeCaseNamingStrategy()
|
||||
|
||||
@column({ isPrimary: true })
|
||||
declare id: number
|
||||
|
||||
@column()
|
||||
declare key: KVStoreKey
|
||||
|
||||
@column()
|
||||
declare value: string | null
|
||||
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare created_at: DateTime
|
||||
|
||||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||
declare updated_at: DateTime
|
||||
|
||||
/**
|
||||
* Get a setting value by key, automatically deserializing to the correct type.
|
||||
*/
|
||||
static async getValue<K extends KVStoreKey>(key: K): Promise<KVStoreValue<K> | null> {
|
||||
const setting = await this.findBy('key', key)
|
||||
if (!setting || setting.value === undefined || setting.value === null) {
|
||||
return null
|
||||
}
|
||||
const raw = String(setting.value)
|
||||
return (KV_STORE_SCHEMA[key] === 'boolean' ? parseBoolean(raw) : raw) as KVStoreValue<K>
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a setting value by key (creates if not exists), automatically serializing to string.
|
||||
*/
|
||||
static async setValue<K extends KVStoreKey>(key: K, value: KVStoreValue<K>): Promise<KVStore> {
|
||||
const serialized = String(value)
|
||||
const setting = await this.firstOrCreate({ key }, { key, value: serialized })
|
||||
if (setting.value !== serialized) {
|
||||
setting.value = serialized
|
||||
await setting.save()
|
||||
}
|
||||
return setting
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a setting value by key, storing null so getValue returns null.
|
||||
*/
|
||||
static async clearValue<K extends KVStoreKey>(key: K): Promise<void> {
|
||||
const setting = await this.findBy('key', key)
|
||||
if (setting && setting.value !== null) {
|
||||
setting.value = null
|
||||
await setting.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,12 @@ export default class Service extends BaseModel {
|
|||
@column()
|
||||
declare description: string | null
|
||||
|
||||
@column()
|
||||
declare powered_by: string | null
|
||||
|
||||
@column()
|
||||
declare display_order: number | null
|
||||
|
||||
@column()
|
||||
declare icon: string | null // must be a TablerIcons name to be properly rendered in the UI (e.g. "IconBrandDocker")
|
||||
|
||||
|
|
@ -56,6 +62,15 @@ export default class Service extends BaseModel {
|
|||
@column()
|
||||
declare metadata: string | null
|
||||
|
||||
@column()
|
||||
declare source_repo: string | null
|
||||
|
||||
@column()
|
||||
declare available_update_version: string | null
|
||||
|
||||
@column.dateTime()
|
||||
declare update_checked_at: DateTime | null
|
||||
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare created_at: DateTime
|
||||
|
||||
|
|
|
|||
27
admin/app/models/wikipedia_selection.ts
Normal file
27
admin/app/models/wikipedia_selection.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { DateTime } from 'luxon'
|
||||
import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
|
||||
|
||||
export default class WikipediaSelection extends BaseModel {
|
||||
static namingStrategy = new SnakeCaseNamingStrategy()
|
||||
|
||||
@column({ isPrimary: true })
|
||||
declare id: number
|
||||
|
||||
@column()
|
||||
declare option_id: string
|
||||
|
||||
@column()
|
||||
declare url: string | null
|
||||
|
||||
@column()
|
||||
declare filename: string | null
|
||||
|
||||
@column()
|
||||
declare status: 'none' | 'downloading' | 'installed' | 'failed'
|
||||
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare created_at: DateTime
|
||||
|
||||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||
declare updated_at: DateTime
|
||||
}
|
||||
834
admin/app/services/benchmark_service.ts
Normal file
834
admin/app/services/benchmark_service.ts
Normal file
|
|
@ -0,0 +1,834 @@
|
|||
import { inject } from '@adonisjs/core'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import transmit from '@adonisjs/transmit/services/main'
|
||||
import si from 'systeminformation'
|
||||
import axios from 'axios'
|
||||
import { DateTime } from 'luxon'
|
||||
import BenchmarkResult from '#models/benchmark_result'
|
||||
import BenchmarkSetting from '#models/benchmark_setting'
|
||||
import { SystemService } from '#services/system_service'
|
||||
import type {
|
||||
BenchmarkType,
|
||||
BenchmarkStatus,
|
||||
BenchmarkProgress,
|
||||
HardwareInfo,
|
||||
DiskType,
|
||||
SystemScores,
|
||||
AIScores,
|
||||
SysbenchCpuResult,
|
||||
SysbenchMemoryResult,
|
||||
SysbenchDiskResult,
|
||||
RepositorySubmission,
|
||||
RepositorySubmitResponse,
|
||||
RepositoryStats,
|
||||
} from '../../types/benchmark.js'
|
||||
import { randomUUID, createHmac } from 'node:crypto'
|
||||
import { DockerService } from './docker_service.js'
|
||||
import { SERVICE_NAMES } from '../../constants/service_names.js'
|
||||
import { BROADCAST_CHANNELS } from '../../constants/broadcast.js'
|
||||
import Dockerode from 'dockerode'
|
||||
|
||||
// HMAC secret for signing submissions to the benchmark repository
|
||||
// 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 = '778ba65d0bc0e23119e5ffce4b3716648a7d071f0a47ec3f'
|
||||
|
||||
// Re-export default weights for use in service
|
||||
const SCORE_WEIGHTS = {
|
||||
ai_tokens_per_second: 0.30,
|
||||
cpu: 0.25,
|
||||
memory: 0.15,
|
||||
ai_ttft: 0.10,
|
||||
disk_read: 0.10,
|
||||
disk_write: 0.10,
|
||||
}
|
||||
|
||||
// Benchmark configuration constants
|
||||
const SYSBENCH_IMAGE = 'severalnines/sysbench:latest'
|
||||
const SYSBENCH_CONTAINER_NAME = 'nomad_benchmark_sysbench'
|
||||
|
||||
// Reference model for AI benchmark - small but meaningful
|
||||
const AI_BENCHMARK_MODEL = 'llama3.2:1b'
|
||||
const AI_BENCHMARK_PROMPT = 'Explain recursion in programming in exactly 100 words.'
|
||||
|
||||
// Reference scores for normalization (calibrated to 0-100 scale)
|
||||
// These represent "expected" scores for a mid-range system (score ~50)
|
||||
const REFERENCE_SCORES = {
|
||||
cpu_events_per_second: 5000, // sysbench cpu events/sec for ~50 score
|
||||
memory_ops_per_second: 5000000, // sysbench memory ops/sec for ~50 score
|
||||
disk_read_mb_per_sec: 500, // 500 MB/s read for ~50 score
|
||||
disk_write_mb_per_sec: 400, // 400 MB/s write for ~50 score
|
||||
ai_tokens_per_second: 30, // 30 tok/s for ~50 score
|
||||
ai_ttft_ms: 500, // 500ms time to first token for ~50 score (lower is better)
|
||||
}
|
||||
|
||||
@inject()
|
||||
export class BenchmarkService {
|
||||
private currentBenchmarkId: string | null = null
|
||||
private currentStatus: BenchmarkStatus = 'idle'
|
||||
|
||||
constructor(private dockerService: DockerService) {}
|
||||
|
||||
/**
|
||||
* Run a full benchmark suite
|
||||
*/
|
||||
async runFullBenchmark(): Promise<BenchmarkResult> {
|
||||
return this._runBenchmark('full', true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run system benchmarks only (CPU, memory, disk)
|
||||
*/
|
||||
async runSystemBenchmarks(): Promise<BenchmarkResult> {
|
||||
return this._runBenchmark('system', false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run AI benchmark only
|
||||
*/
|
||||
async runAIBenchmark(): Promise<BenchmarkResult> {
|
||||
return this._runBenchmark('ai', true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest benchmark result
|
||||
*/
|
||||
async getLatestResult(): Promise<BenchmarkResult | null> {
|
||||
return await BenchmarkResult.query().orderBy('created_at', 'desc').first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all benchmark results
|
||||
*/
|
||||
async getAllResults(): Promise<BenchmarkResult[]> {
|
||||
return await BenchmarkResult.query().orderBy('created_at', 'desc')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific benchmark result by ID
|
||||
*/
|
||||
async getResultById(benchmarkId: string): Promise<BenchmarkResult | null> {
|
||||
return await BenchmarkResult.findBy('benchmark_id', benchmarkId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit benchmark results to central repository
|
||||
*/
|
||||
async submitToRepository(benchmarkId?: string, anonymous?: boolean): Promise<RepositorySubmitResponse> {
|
||||
const result = benchmarkId
|
||||
? await this.getResultById(benchmarkId)
|
||||
: await this.getLatestResult()
|
||||
|
||||
if (!result) {
|
||||
throw new Error('No benchmark result found to submit')
|
||||
}
|
||||
|
||||
// Only allow full benchmarks with AI data to be submitted to repository
|
||||
if (result.benchmark_type !== 'full') {
|
||||
throw new Error('Only full benchmarks can be shared with the community. Run a Full Benchmark to share your results.')
|
||||
}
|
||||
|
||||
if (!result.ai_tokens_per_second || result.ai_tokens_per_second <= 0) {
|
||||
throw new Error('Benchmark must include AI performance data. Ensure AI Assistant is installed and run a Full Benchmark.')
|
||||
}
|
||||
|
||||
if (result.submitted_to_repository) {
|
||||
throw new Error('Benchmark result has already been submitted')
|
||||
}
|
||||
|
||||
const submission: RepositorySubmission = {
|
||||
cpu_model: result.cpu_model,
|
||||
cpu_cores: result.cpu_cores,
|
||||
cpu_threads: result.cpu_threads,
|
||||
ram_gb: Math.round(result.ram_bytes / (1024 * 1024 * 1024)),
|
||||
disk_type: result.disk_type,
|
||||
gpu_model: result.gpu_model,
|
||||
cpu_score: result.cpu_score,
|
||||
memory_score: result.memory_score,
|
||||
disk_read_score: result.disk_read_score,
|
||||
disk_write_score: result.disk_write_score,
|
||||
ai_tokens_per_second: result.ai_tokens_per_second,
|
||||
ai_time_to_first_token: result.ai_time_to_first_token,
|
||||
nomad_score: result.nomad_score,
|
||||
nomad_version: SystemService.getAppVersion(),
|
||||
benchmark_version: '1.0.0',
|
||||
builder_tag: anonymous ? null : result.builder_tag,
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate HMAC signature for submission verification
|
||||
const timestamp = Date.now().toString()
|
||||
const payload = timestamp + JSON.stringify(submission)
|
||||
const signature = createHmac('sha256', BENCHMARK_HMAC_SECRET)
|
||||
.update(payload)
|
||||
.digest('hex')
|
||||
|
||||
const response = await axios.post(
|
||||
'https://benchmark.projectnomad.us/api/v1/submit',
|
||||
submission,
|
||||
{
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'X-NOMAD-Timestamp': timestamp,
|
||||
'X-NOMAD-Signature': signature,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (response.data.success) {
|
||||
result.submitted_to_repository = true
|
||||
result.submitted_at = DateTime.now()
|
||||
result.repository_id = response.data.repository_id
|
||||
await result.save()
|
||||
|
||||
await BenchmarkSetting.setValue('last_benchmark_run', new Date().toISOString())
|
||||
}
|
||||
|
||||
return response.data as RepositorySubmitResponse
|
||||
} catch (error) {
|
||||
const detail = error.response?.data?.error || error.message || 'Unknown error'
|
||||
const statusCode = error.response?.status
|
||||
logger.error(`Failed to submit benchmark to repository: ${detail} (Status: ${statusCode})`)
|
||||
|
||||
// Create an error with the status code attached for proper handling upstream
|
||||
const err: any = new Error(`Failed to submit benchmark: ${detail}`)
|
||||
err.statusCode = statusCode
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comparison stats from central repository
|
||||
*/
|
||||
async getComparisonStats(): Promise<RepositoryStats | null> {
|
||||
try {
|
||||
const response = await axios.get('https://benchmark.projectnomad.us/api/v1/stats', {
|
||||
timeout: 10000,
|
||||
})
|
||||
return response.data as RepositoryStats
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to fetch comparison stats: ${error.message}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current benchmark status
|
||||
*/
|
||||
getStatus(): { status: BenchmarkStatus; benchmarkId: string | null } {
|
||||
return {
|
||||
status: this.currentStatus,
|
||||
benchmarkId: this.currentBenchmarkId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect system hardware information
|
||||
*/
|
||||
async getHardwareInfo(): Promise<HardwareInfo> {
|
||||
this._updateStatus('detecting_hardware', 'Detecting system hardware...')
|
||||
|
||||
try {
|
||||
const [cpu, mem, diskLayout, graphics] = await Promise.all([
|
||||
si.cpu(),
|
||||
si.mem(),
|
||||
si.diskLayout(),
|
||||
si.graphics(),
|
||||
])
|
||||
|
||||
// Determine disk type from primary disk
|
||||
let diskType: DiskType = 'unknown'
|
||||
if (diskLayout.length > 0) {
|
||||
const primaryDisk = diskLayout[0]
|
||||
if (primaryDisk.type?.toLowerCase().includes('nvme')) {
|
||||
diskType = 'nvme'
|
||||
} else if (primaryDisk.type?.toLowerCase().includes('ssd')) {
|
||||
diskType = 'ssd'
|
||||
} else if (primaryDisk.type?.toLowerCase().includes('hdd') || primaryDisk.interfaceType === 'SATA') {
|
||||
// SATA could be SSD or HDD, check if it's rotational
|
||||
diskType = 'hdd'
|
||||
}
|
||||
}
|
||||
|
||||
// Get GPU model (prefer discrete GPU with dedicated VRAM)
|
||||
let gpuModel: string | null = null
|
||||
if (graphics.controllers && graphics.controllers.length > 0) {
|
||||
// First, look for discrete GPUs (NVIDIA, AMD discrete, or any with significant VRAM)
|
||||
const discreteGpu = graphics.controllers.find((g) => {
|
||||
const vendor = g.vendor?.toLowerCase() || ''
|
||||
const model = g.model?.toLowerCase() || ''
|
||||
// NVIDIA GPUs are always discrete
|
||||
if (vendor.includes('nvidia') || model.includes('geforce') || model.includes('rtx') || model.includes('quadro')) {
|
||||
return true
|
||||
}
|
||||
// AMD discrete GPUs (Radeon, not integrated APU graphics)
|
||||
if ((vendor.includes('amd') || vendor.includes('ati')) &&
|
||||
(model.includes('radeon') || model.includes('rx ') || model.includes('vega')) &&
|
||||
!model.includes('graphics')) {
|
||||
return true
|
||||
}
|
||||
// Any GPU with dedicated VRAM > 512MB is likely discrete
|
||||
if (g.vram && g.vram > 512) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
gpuModel = discreteGpu?.model || graphics.controllers[0]?.model || null
|
||||
}
|
||||
|
||||
// Fallback: Check Docker for nvidia runtime and query GPU model via nvidia-smi
|
||||
if (!gpuModel) {
|
||||
try {
|
||||
const dockerInfo = await this.dockerService.docker.info()
|
||||
const runtimes = dockerInfo.Runtimes || {}
|
||||
if ('nvidia' in runtimes) {
|
||||
logger.info('[BenchmarkService] NVIDIA container runtime detected, querying GPU model via nvidia-smi')
|
||||
|
||||
const systemService = new (await import('./system_service.js')).SystemService(this.dockerService)
|
||||
const nvidiaInfo = await systemService.getNvidiaSmiInfo()
|
||||
if (Array.isArray(nvidiaInfo) && nvidiaInfo.length > 0) {
|
||||
gpuModel = nvidiaInfo[0].model
|
||||
} else {
|
||||
logger.warn(`[BenchmarkService] NVIDIA runtime detected but failed to get GPU info: ${typeof nvidiaInfo === 'string' ? nvidiaInfo : JSON.stringify(nvidiaInfo)}`)
|
||||
}
|
||||
}
|
||||
} catch (dockerError) {
|
||||
logger.warn(`[BenchmarkService] Could not query Docker info for GPU detection: ${dockerError.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Extract integrated GPU from CPU model name
|
||||
if (!gpuModel) {
|
||||
const cpuFullName = `${cpu.manufacturer} ${cpu.brand}`
|
||||
|
||||
// AMD APUs: e.g., "AMD Ryzen AI 9 HX 370 w/ Radeon 890M" -> "Radeon 890M"
|
||||
const radeonMatch = cpuFullName.match(/w\/\s*(Radeon\s+\d+\w*)/i)
|
||||
if (radeonMatch) {
|
||||
gpuModel = radeonMatch[1]
|
||||
}
|
||||
|
||||
// Intel Core Ultra: These have Intel Arc Graphics integrated
|
||||
// e.g., "Intel Core Ultra 9 285HX" -> "Intel Arc Graphics (Integrated)"
|
||||
if (!gpuModel && cpu.manufacturer?.toLowerCase().includes('intel')) {
|
||||
if (cpu.brand?.toLowerCase().includes('core ultra')) {
|
||||
gpuModel = 'Intel Arc Graphics (Integrated)'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cpu_model: `${cpu.manufacturer} ${cpu.brand}`,
|
||||
cpu_cores: cpu.physicalCores,
|
||||
cpu_threads: cpu.cores,
|
||||
ram_bytes: mem.total,
|
||||
disk_type: diskType,
|
||||
gpu_model: gpuModel,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error detecting hardware: ${error.message}`)
|
||||
throw new Error(`Failed to detect hardware: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main benchmark execution method
|
||||
*/
|
||||
private async _runBenchmark(type: BenchmarkType, includeAI: boolean): Promise<BenchmarkResult> {
|
||||
if (this.currentStatus !== 'idle') {
|
||||
throw new Error('A benchmark is already running')
|
||||
}
|
||||
|
||||
this.currentBenchmarkId = randomUUID()
|
||||
this._updateStatus('starting', 'Starting benchmark...')
|
||||
|
||||
try {
|
||||
// Detect hardware
|
||||
const hardware = await this.getHardwareInfo()
|
||||
|
||||
// Run system benchmarks
|
||||
let systemScores: SystemScores = {
|
||||
cpu_score: 0,
|
||||
memory_score: 0,
|
||||
disk_read_score: 0,
|
||||
disk_write_score: 0,
|
||||
}
|
||||
|
||||
if (type === 'full' || type === 'system') {
|
||||
systemScores = await this._runSystemBenchmarks()
|
||||
}
|
||||
|
||||
// Run AI benchmark if requested and Ollama is available
|
||||
let aiScores: Partial<AIScores> = {}
|
||||
if (includeAI && (type === 'full' || type === 'ai')) {
|
||||
try {
|
||||
aiScores = await this._runAIBenchmark()
|
||||
} catch (error) {
|
||||
// For AI-only benchmarks, failing is fatal - don't save useless results with all zeros
|
||||
if (type === 'ai') {
|
||||
throw new Error(`AI benchmark failed: ${error.message}. Make sure AI Assistant is installed and running.`)
|
||||
}
|
||||
// For full benchmarks, AI is optional - continue without it
|
||||
logger.warn(`AI benchmark skipped: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate NOMAD score
|
||||
this._updateStatus('calculating_score', 'Calculating NOMAD score...')
|
||||
const nomadScore = this._calculateNomadScore(systemScores, aiScores)
|
||||
|
||||
// Save result
|
||||
const result = await BenchmarkResult.create({
|
||||
benchmark_id: this.currentBenchmarkId,
|
||||
benchmark_type: type,
|
||||
cpu_model: hardware.cpu_model,
|
||||
cpu_cores: hardware.cpu_cores,
|
||||
cpu_threads: hardware.cpu_threads,
|
||||
ram_bytes: hardware.ram_bytes,
|
||||
disk_type: hardware.disk_type,
|
||||
gpu_model: hardware.gpu_model,
|
||||
cpu_score: systemScores.cpu_score,
|
||||
memory_score: systemScores.memory_score,
|
||||
disk_read_score: systemScores.disk_read_score,
|
||||
disk_write_score: systemScores.disk_write_score,
|
||||
ai_tokens_per_second: aiScores.ai_tokens_per_second || null,
|
||||
ai_model_used: aiScores.ai_model_used || null,
|
||||
ai_time_to_first_token: aiScores.ai_time_to_first_token || null,
|
||||
nomad_score: nomadScore,
|
||||
submitted_to_repository: false,
|
||||
})
|
||||
|
||||
this._updateStatus('completed', 'Benchmark completed successfully')
|
||||
this.currentStatus = 'idle'
|
||||
this.currentBenchmarkId = null
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
this._updateStatus('error', `Benchmark failed: ${error.message}`)
|
||||
this.currentStatus = 'idle'
|
||||
this.currentBenchmarkId = null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run system benchmarks using sysbench in Docker
|
||||
*/
|
||||
private async _runSystemBenchmarks(): Promise<SystemScores> {
|
||||
// Ensure sysbench image is available
|
||||
await this._ensureSysbenchImage()
|
||||
|
||||
// Run CPU benchmark
|
||||
this._updateStatus('running_cpu', 'Running CPU benchmark...')
|
||||
const cpuResult = await this._runSysbenchCpu()
|
||||
|
||||
// Run memory benchmark
|
||||
this._updateStatus('running_memory', 'Running memory benchmark...')
|
||||
const memoryResult = await this._runSysbenchMemory()
|
||||
|
||||
// Run disk benchmarks
|
||||
this._updateStatus('running_disk_read', 'Running disk read benchmark...')
|
||||
const diskReadResult = await this._runSysbenchDiskRead()
|
||||
|
||||
this._updateStatus('running_disk_write', 'Running disk write benchmark...')
|
||||
const diskWriteResult = await this._runSysbenchDiskWrite()
|
||||
|
||||
// Normalize scores to 0-100 scale
|
||||
return {
|
||||
cpu_score: this._normalizeScore(cpuResult.events_per_second, REFERENCE_SCORES.cpu_events_per_second),
|
||||
memory_score: this._normalizeScore(memoryResult.operations_per_second, REFERENCE_SCORES.memory_ops_per_second),
|
||||
disk_read_score: this._normalizeScore(diskReadResult.read_mb_per_sec, REFERENCE_SCORES.disk_read_mb_per_sec),
|
||||
disk_write_score: this._normalizeScore(diskWriteResult.write_mb_per_sec, REFERENCE_SCORES.disk_write_mb_per_sec),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run AI benchmark using Ollama
|
||||
*/
|
||||
private async _runAIBenchmark(): Promise<AIScores> {
|
||||
try {
|
||||
|
||||
this._updateStatus('running_ai', 'Running AI benchmark...')
|
||||
|
||||
const ollamaAPIURL = await this.dockerService.getServiceURL(SERVICE_NAMES.OLLAMA)
|
||||
if (!ollamaAPIURL) {
|
||||
throw new Error('AI Assistant service location could not be determined. Ensure AI Assistant is installed and running.')
|
||||
}
|
||||
|
||||
// Check if Ollama is available
|
||||
try {
|
||||
await axios.get(`${ollamaAPIURL}/api/tags`, { timeout: 5000 })
|
||||
} catch (error) {
|
||||
const errorCode = error.code || error.response?.status || 'unknown'
|
||||
throw new Error(`Ollama is not running or not accessible (${errorCode}). Ensure AI Assistant is installed and running.`)
|
||||
}
|
||||
|
||||
// Check if the benchmark model is available, pull if not
|
||||
const ollamaService = new (await import('./ollama_service.js')).OllamaService()
|
||||
const modelResponse = await ollamaService.downloadModel(AI_BENCHMARK_MODEL)
|
||||
if (!modelResponse.success) {
|
||||
throw new Error(`Model does not exist and failed to download: ${modelResponse.message}`)
|
||||
}
|
||||
|
||||
// Run inference benchmark
|
||||
const startTime = Date.now()
|
||||
|
||||
const response = await axios.post(
|
||||
`${ollamaAPIURL}/api/generate`,
|
||||
{
|
||||
model: AI_BENCHMARK_MODEL,
|
||||
prompt: AI_BENCHMARK_PROMPT,
|
||||
stream: false,
|
||||
},
|
||||
{ timeout: 120000 }
|
||||
)
|
||||
|
||||
const endTime = Date.now()
|
||||
const totalTime = (endTime - startTime) / 1000 // seconds
|
||||
|
||||
// Ollama returns eval_count (tokens generated) and eval_duration (nanoseconds)
|
||||
if (response.data.eval_count && response.data.eval_duration) {
|
||||
const tokenCount = response.data.eval_count
|
||||
const evalDurationSeconds = response.data.eval_duration / 1e9
|
||||
const tokensPerSecond = tokenCount / evalDurationSeconds
|
||||
|
||||
// Time to first token from prompt_eval_duration
|
||||
const ttft = response.data.prompt_eval_duration
|
||||
? response.data.prompt_eval_duration / 1e6 // Convert to ms
|
||||
: (totalTime * 1000) / 2 // Estimate if not available
|
||||
|
||||
return {
|
||||
ai_tokens_per_second: Math.round(tokensPerSecond * 100) / 100,
|
||||
ai_model_used: AI_BENCHMARK_MODEL,
|
||||
ai_time_to_first_token: Math.round(ttft * 100) / 100,
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback calculation
|
||||
const estimatedTokens = response.data.response?.split(' ').length * 1.3 || 100
|
||||
const tokensPerSecond = estimatedTokens / totalTime
|
||||
|
||||
return {
|
||||
ai_tokens_per_second: Math.round(tokensPerSecond * 100) / 100,
|
||||
ai_model_used: AI_BENCHMARK_MODEL,
|
||||
ai_time_to_first_token: Math.round((totalTime * 1000) / 2),
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`AI benchmark failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate weighted NOMAD score
|
||||
*/
|
||||
private _calculateNomadScore(systemScores: SystemScores, aiScores: Partial<AIScores>): number {
|
||||
let totalWeight = 0
|
||||
let weightedSum = 0
|
||||
|
||||
// CPU score
|
||||
weightedSum += systemScores.cpu_score * SCORE_WEIGHTS.cpu
|
||||
totalWeight += SCORE_WEIGHTS.cpu
|
||||
|
||||
// Memory score
|
||||
weightedSum += systemScores.memory_score * SCORE_WEIGHTS.memory
|
||||
totalWeight += SCORE_WEIGHTS.memory
|
||||
|
||||
// Disk scores
|
||||
weightedSum += systemScores.disk_read_score * SCORE_WEIGHTS.disk_read
|
||||
totalWeight += SCORE_WEIGHTS.disk_read
|
||||
weightedSum += systemScores.disk_write_score * SCORE_WEIGHTS.disk_write
|
||||
totalWeight += SCORE_WEIGHTS.disk_write
|
||||
|
||||
// AI scores (if available)
|
||||
if (aiScores.ai_tokens_per_second !== undefined && aiScores.ai_tokens_per_second !== null) {
|
||||
const aiScore = this._normalizeScore(
|
||||
aiScores.ai_tokens_per_second,
|
||||
REFERENCE_SCORES.ai_tokens_per_second
|
||||
)
|
||||
weightedSum += aiScore * SCORE_WEIGHTS.ai_tokens_per_second
|
||||
totalWeight += SCORE_WEIGHTS.ai_tokens_per_second
|
||||
}
|
||||
|
||||
if (aiScores.ai_time_to_first_token !== undefined && aiScores.ai_time_to_first_token !== null) {
|
||||
// For TTFT, lower is better, so we invert the score
|
||||
const ttftScore = this._normalizeScoreInverse(
|
||||
aiScores.ai_time_to_first_token,
|
||||
REFERENCE_SCORES.ai_ttft_ms
|
||||
)
|
||||
weightedSum += ttftScore * SCORE_WEIGHTS.ai_ttft
|
||||
totalWeight += SCORE_WEIGHTS.ai_ttft
|
||||
}
|
||||
|
||||
// Normalize by actual weight used (in case AI benchmarks were skipped)
|
||||
const nomadScore = totalWeight > 0 ? (weightedSum / totalWeight) * 100 : 0
|
||||
|
||||
return Math.round(Math.min(100, Math.max(0, nomadScore)) * 100) / 100
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw score to 0-100 scale using log scaling
|
||||
* This provides diminishing returns for very high scores
|
||||
*/
|
||||
private _normalizeScore(value: number, reference: number): number {
|
||||
if (value <= 0) return 0
|
||||
// Log scale with widened range: dividing log2 by 3 prevents scores from
|
||||
// clamping to 0% for below-average hardware. Gives 50% at reference value.
|
||||
const ratio = value / reference
|
||||
const score = 50 * (1 + Math.log2(Math.max(0.01, ratio)) / 3)
|
||||
return Math.min(100, Math.max(0, score)) / 100
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a score where lower is better (like latency)
|
||||
*/
|
||||
private _normalizeScoreInverse(value: number, reference: number): number {
|
||||
if (value <= 0) return 1
|
||||
// Inverse: lower values = higher scores, with widened log range
|
||||
const ratio = reference / value
|
||||
const score = 50 * (1 + Math.log2(Math.max(0.01, ratio)) / 3)
|
||||
return Math.min(100, Math.max(0, score)) / 100
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure sysbench Docker image is available
|
||||
*/
|
||||
private async _ensureSysbenchImage(): Promise<void> {
|
||||
try {
|
||||
await this.dockerService.docker.getImage(SYSBENCH_IMAGE).inspect()
|
||||
} catch {
|
||||
this._updateStatus('starting', `Pulling sysbench image...`)
|
||||
const pullStream = await this.dockerService.docker.pull(SYSBENCH_IMAGE)
|
||||
await new Promise((resolve) => this.dockerService.docker.modem.followProgress(pullStream, resolve))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run sysbench CPU benchmark
|
||||
*/
|
||||
private async _runSysbenchCpu(): Promise<SysbenchCpuResult> {
|
||||
const output = await this._runSysbenchCommand([
|
||||
'sysbench',
|
||||
'cpu',
|
||||
'--cpu-max-prime=20000',
|
||||
'--threads=4',
|
||||
'--time=30',
|
||||
'run',
|
||||
])
|
||||
|
||||
// Parse output for events per second
|
||||
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,
|
||||
total_time: totalTimeMatch ? parseFloat(totalTimeMatch[1]) : 30,
|
||||
total_events: totalEventsMatch ? parseInt(totalEventsMatch[1]) : 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run sysbench memory benchmark
|
||||
*/
|
||||
private async _runSysbenchMemory(): Promise<SysbenchMemoryResult> {
|
||||
const output = await this._runSysbenchCommand([
|
||||
'sysbench',
|
||||
'memory',
|
||||
'--memory-block-size=1K',
|
||||
'--memory-total-size=10G',
|
||||
'--threads=4',
|
||||
'run',
|
||||
])
|
||||
|
||||
// Parse output
|
||||
const opsMatch = output.match(/Total operations:\s*\d+\s*\(([\d.]+)\s*per second\)/i)
|
||||
const transferMatch = output.match(/([\d.]+)\s*MiB\/sec/i)
|
||||
const timeMatch = output.match(/total time:\s*([\d.]+)s/i)
|
||||
|
||||
return {
|
||||
operations_per_second: opsMatch ? parseFloat(opsMatch[1]) : 0,
|
||||
transfer_rate_mb_per_sec: transferMatch ? parseFloat(transferMatch[1]) : 0,
|
||||
total_time: timeMatch ? parseFloat(timeMatch[1]) : 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run sysbench disk read benchmark
|
||||
*/
|
||||
private async _runSysbenchDiskRead(): Promise<SysbenchDiskResult> {
|
||||
// Run prepare, test, and cleanup in a single container
|
||||
// This is necessary because each container has its own filesystem
|
||||
const output = await this._runSysbenchCommand([
|
||||
'sh',
|
||||
'-c',
|
||||
'sysbench fileio --file-total-size=1G --file-num=4 prepare && ' +
|
||||
'sysbench fileio --file-total-size=1G --file-num=4 --file-test-mode=seqrd --time=30 run && ' +
|
||||
'sysbench fileio --file-total-size=1G --file-num=4 cleanup',
|
||||
])
|
||||
|
||||
// Parse output - look for the Throughput section
|
||||
const readMatch = output.match(/read,\s*MiB\/s:\s*([\d.]+)/i)
|
||||
const readsPerSecMatch = output.match(/reads\/s:\s*([\d.]+)/i)
|
||||
|
||||
logger.debug(`[BenchmarkService] Disk read output parsing - read: ${readMatch?.[1]}, reads/s: ${readsPerSecMatch?.[1]}`)
|
||||
|
||||
return {
|
||||
reads_per_second: readsPerSecMatch ? parseFloat(readsPerSecMatch[1]) : 0,
|
||||
writes_per_second: 0,
|
||||
read_mb_per_sec: readMatch ? parseFloat(readMatch[1]) : 0,
|
||||
write_mb_per_sec: 0,
|
||||
total_time: 30,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run sysbench disk write benchmark
|
||||
*/
|
||||
private async _runSysbenchDiskWrite(): Promise<SysbenchDiskResult> {
|
||||
// Run prepare, test, and cleanup in a single container
|
||||
// This is necessary because each container has its own filesystem
|
||||
const output = await this._runSysbenchCommand([
|
||||
'sh',
|
||||
'-c',
|
||||
'sysbench fileio --file-total-size=1G --file-num=4 prepare && ' +
|
||||
'sysbench fileio --file-total-size=1G --file-num=4 --file-test-mode=seqwr --time=30 run && ' +
|
||||
'sysbench fileio --file-total-size=1G --file-num=4 cleanup',
|
||||
])
|
||||
|
||||
// Parse output - look for the Throughput section
|
||||
const writeMatch = output.match(/written,\s*MiB\/s:\s*([\d.]+)/i)
|
||||
const writesPerSecMatch = output.match(/writes\/s:\s*([\d.]+)/i)
|
||||
|
||||
logger.debug(`[BenchmarkService] Disk write output parsing - written: ${writeMatch?.[1]}, writes/s: ${writesPerSecMatch?.[1]}`)
|
||||
|
||||
return {
|
||||
reads_per_second: 0,
|
||||
writes_per_second: writesPerSecMatch ? parseFloat(writesPerSecMatch[1]) : 0,
|
||||
read_mb_per_sec: 0,
|
||||
write_mb_per_sec: writeMatch ? parseFloat(writeMatch[1]) : 0,
|
||||
total_time: 30,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a sysbench command in a Docker container
|
||||
*/
|
||||
private async _runSysbenchCommand(cmd: string[]): Promise<string> {
|
||||
let container: Dockerode.Container | null = null
|
||||
try {
|
||||
// Create container with TTY to avoid multiplexed output
|
||||
container = await this.dockerService.docker.createContainer({
|
||||
Image: SYSBENCH_IMAGE,
|
||||
Cmd: cmd,
|
||||
name: `${SYSBENCH_CONTAINER_NAME}_${Date.now()}`,
|
||||
Tty: true, // Important: prevents multiplexed stdout/stderr headers
|
||||
HostConfig: {
|
||||
AutoRemove: false, // Don't auto-remove to avoid race condition with fetching logs
|
||||
},
|
||||
})
|
||||
|
||||
// Start container
|
||||
await container.start()
|
||||
|
||||
// Wait for completion
|
||||
await container.wait()
|
||||
|
||||
// Get logs after container has finished
|
||||
const logs = await container.logs({
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
})
|
||||
|
||||
// Parse logs (Docker logs include header bytes)
|
||||
const output = logs.toString('utf8')
|
||||
.replace(/[\x00-\x08]/g, '') // Remove control characters
|
||||
.trim()
|
||||
|
||||
// Manually remove the container after getting logs
|
||||
try {
|
||||
await container.remove()
|
||||
} catch (removeError) {
|
||||
// Log but don't fail if removal fails (container might already be gone)
|
||||
logger.warn(`Failed to remove sysbench container: ${removeError.message}`)
|
||||
}
|
||||
|
||||
return output
|
||||
} catch (error) {
|
||||
// Clean up container on error if it exists
|
||||
if (container) {
|
||||
try {
|
||||
await container.remove({ force: true })
|
||||
} catch (removeError) {
|
||||
// Ignore removal errors
|
||||
}
|
||||
}
|
||||
logger.error(`Sysbench command failed: ${error.message}`)
|
||||
throw new Error(`Sysbench command failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast benchmark progress update
|
||||
*/
|
||||
private _updateStatus(status: BenchmarkStatus, message: string) {
|
||||
this.currentStatus = status
|
||||
|
||||
const progress: BenchmarkProgress = {
|
||||
status,
|
||||
progress: this._getProgressPercent(status),
|
||||
message,
|
||||
current_stage: this._getStageLabel(status),
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
transmit.broadcast(BROADCAST_CHANNELS.BENCHMARK_PROGRESS, {
|
||||
benchmark_id: this.currentBenchmarkId,
|
||||
...progress,
|
||||
})
|
||||
|
||||
logger.info(`[BenchmarkService] ${status}: ${message}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress percentage for a given status
|
||||
*/
|
||||
private _getProgressPercent(status: BenchmarkStatus): number {
|
||||
const progressMap: Record<BenchmarkStatus, number> = {
|
||||
idle: 0,
|
||||
starting: 5,
|
||||
detecting_hardware: 10,
|
||||
running_cpu: 25,
|
||||
running_memory: 40,
|
||||
running_disk_read: 55,
|
||||
running_disk_write: 70,
|
||||
downloading_ai_model: 80,
|
||||
running_ai: 85,
|
||||
calculating_score: 95,
|
||||
completed: 100,
|
||||
error: 0,
|
||||
}
|
||||
return progressMap[status] || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable stage label
|
||||
*/
|
||||
private _getStageLabel(status: BenchmarkStatus): string {
|
||||
const labelMap: Record<BenchmarkStatus, string> = {
|
||||
idle: 'Idle',
|
||||
starting: 'Starting',
|
||||
detecting_hardware: 'Detecting Hardware',
|
||||
running_cpu: 'CPU Benchmark',
|
||||
running_memory: 'Memory Benchmark',
|
||||
running_disk_read: 'Disk Read Test',
|
||||
running_disk_write: 'Disk Write Test',
|
||||
downloading_ai_model: 'Downloading AI Model',
|
||||
running_ai: 'AI Inference Test',
|
||||
calculating_score: 'Calculating Score',
|
||||
completed: 'Complete',
|
||||
error: 'Error',
|
||||
}
|
||||
return labelMap[status] || status
|
||||
}
|
||||
}
|
||||
289
admin/app/services/chat_service.ts
Normal file
289
admin/app/services/chat_service.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import ChatSession from '#models/chat_session'
|
||||
import ChatMessage from '#models/chat_message'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import { DateTime } from 'luxon'
|
||||
import { inject } from '@adonisjs/core'
|
||||
import { OllamaService } from './ollama_service.js'
|
||||
import { DEFAULT_QUERY_REWRITE_MODEL, SYSTEM_PROMPTS } from '../../constants/ollama.js'
|
||||
import { toTitleCase } from '../utils/misc.js'
|
||||
|
||||
@inject()
|
||||
export class ChatService {
|
||||
constructor(private ollamaService: OllamaService) {}
|
||||
|
||||
async getAllSessions() {
|
||||
try {
|
||||
const sessions = await ChatSession.query().orderBy('updated_at', 'desc')
|
||||
return sessions.map((session) => ({
|
||||
id: session.id.toString(),
|
||||
title: session.title,
|
||||
model: session.model,
|
||||
timestamp: session.updated_at.toJSDate(),
|
||||
lastMessage: null, // Will be populated from messages if needed
|
||||
}))
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[ChatService] Failed to get sessions: ${error instanceof Error ? error.message : error}`
|
||||
)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async getChatSuggestions() {
|
||||
try {
|
||||
const models = await this.ollamaService.getModels()
|
||||
if (!models || models.length === 0) {
|
||||
return [] // If no models are available, return empty suggestions
|
||||
}
|
||||
|
||||
// Larger models generally give "better" responses, so pick the largest one
|
||||
const largestModel = models.reduce((prev, current) => {
|
||||
return prev.size > current.size ? prev : current
|
||||
})
|
||||
|
||||
if (!largestModel) {
|
||||
return []
|
||||
}
|
||||
|
||||
const response = await this.ollamaService.chat({
|
||||
model: largestModel.name,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: SYSTEM_PROMPTS.chat_suggestions,
|
||||
}
|
||||
],
|
||||
stream: false,
|
||||
})
|
||||
|
||||
if (response && response.message && response.message.content) {
|
||||
const content = response.message.content.trim()
|
||||
|
||||
// Handle both comma-separated and newline-separated formats
|
||||
let suggestions: string[] = []
|
||||
|
||||
// Try splitting by commas first
|
||||
if (content.includes(',')) {
|
||||
suggestions = content.split(',').map((s) => s.trim())
|
||||
}
|
||||
// Fall back to newline separation
|
||||
else {
|
||||
suggestions = content
|
||||
.split(/\r?\n/)
|
||||
.map((s) => s.trim())
|
||||
// Remove numbered list markers (1., 2., 3., etc.) and bullet points
|
||||
.map((s) => s.replace(/^\d+\.\s*/, '').replace(/^[-*•]\s*/, ''))
|
||||
// Remove surrounding quotes if present
|
||||
.map((s) => s.replace(/^["']|["']$/g, ''))
|
||||
}
|
||||
|
||||
// Filter out empty strings and limit to 3 suggestions
|
||||
const filtered = suggestions
|
||||
.filter((s) => s.length > 0)
|
||||
.slice(0, 3)
|
||||
|
||||
return filtered.map((s) => toTitleCase(s))
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[ChatService] Failed to get chat suggestions: ${
|
||||
error instanceof Error ? error.message : error
|
||||
}`
|
||||
)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async getSession(sessionId: number) {
|
||||
try {
|
||||
const session = await ChatSession.query().where('id', sessionId).preload('messages').first()
|
||||
|
||||
if (!session) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: session.id.toString(),
|
||||
title: session.title,
|
||||
model: session.model,
|
||||
timestamp: session.updated_at.toJSDate(),
|
||||
messages: session.messages.map((msg) => ({
|
||||
id: msg.id.toString(),
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
timestamp: msg.created_at.toJSDate(),
|
||||
})),
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[ChatService] Failed to get session ${sessionId}: ${
|
||||
error instanceof Error ? error.message : error
|
||||
}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async createSession(title: string, model?: string) {
|
||||
try {
|
||||
const session = await ChatSession.create({
|
||||
title,
|
||||
model: model || null,
|
||||
})
|
||||
|
||||
return {
|
||||
id: session.id.toString(),
|
||||
title: session.title,
|
||||
model: session.model,
|
||||
timestamp: session.created_at.toJSDate(),
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[ChatService] Failed to create session: ${error instanceof Error ? error.message : error}`
|
||||
)
|
||||
throw new Error('Failed to create chat session')
|
||||
}
|
||||
}
|
||||
|
||||
async updateSession(sessionId: number, data: { title?: string; model?: string }) {
|
||||
try {
|
||||
const session = await ChatSession.findOrFail(sessionId)
|
||||
|
||||
if (data.title) {
|
||||
session.title = data.title
|
||||
}
|
||||
if (data.model !== undefined) {
|
||||
session.model = data.model
|
||||
}
|
||||
|
||||
await session.save()
|
||||
|
||||
return {
|
||||
id: session.id.toString(),
|
||||
title: session.title,
|
||||
model: session.model,
|
||||
timestamp: session.updated_at.toJSDate(),
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[ChatService] Failed to update session ${sessionId}: ${
|
||||
error instanceof Error ? error.message : error
|
||||
}`
|
||||
)
|
||||
throw new Error('Failed to update chat session')
|
||||
}
|
||||
}
|
||||
|
||||
async addMessage(sessionId: number, role: 'system' | 'user' | 'assistant', content: string) {
|
||||
try {
|
||||
const message = await ChatMessage.create({
|
||||
session_id: sessionId,
|
||||
role,
|
||||
content,
|
||||
})
|
||||
|
||||
// Update session's updated_at timestamp
|
||||
const session = await ChatSession.findOrFail(sessionId)
|
||||
session.updated_at = DateTime.now()
|
||||
await session.save()
|
||||
|
||||
return {
|
||||
id: message.id.toString(),
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
timestamp: message.created_at.toJSDate(),
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[ChatService] Failed to add message to session ${sessionId}: ${
|
||||
error instanceof Error ? error.message : error
|
||||
}`
|
||||
)
|
||||
throw new Error('Failed to add message')
|
||||
}
|
||||
}
|
||||
|
||||
async deleteSession(sessionId: number) {
|
||||
try {
|
||||
const session = await ChatSession.findOrFail(sessionId)
|
||||
await session.delete()
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[ChatService] Failed to delete session ${sessionId}: ${
|
||||
error instanceof Error ? error.message : error
|
||||
}`
|
||||
)
|
||||
throw new Error('Failed to delete chat session')
|
||||
}
|
||||
}
|
||||
|
||||
async getMessageCount(sessionId: number): Promise<number> {
|
||||
try {
|
||||
const count = await ChatMessage.query().where('session_id', sessionId).count('* as total')
|
||||
return Number(count[0].$extras.total)
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[ChatService] Failed to get message count for session ${sessionId}: ${error instanceof Error ? error.message : error}`
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
async generateTitle(sessionId: number, userMessage: string, assistantMessage: string) {
|
||||
try {
|
||||
const models = await this.ollamaService.getModels()
|
||||
const titleModelAvailable = models?.some((m) => m.name === DEFAULT_QUERY_REWRITE_MODEL)
|
||||
|
||||
let title: string
|
||||
|
||||
if (!titleModelAvailable) {
|
||||
title = userMessage.slice(0, 57) + (userMessage.length > 57 ? '...' : '')
|
||||
} else {
|
||||
const response = await this.ollamaService.chat({
|
||||
model: DEFAULT_QUERY_REWRITE_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: SYSTEM_PROMPTS.title_generation },
|
||||
{ role: 'user', content: userMessage },
|
||||
{ role: 'assistant', content: assistantMessage },
|
||||
],
|
||||
})
|
||||
|
||||
title = response?.message?.content?.trim()
|
||||
if (!title) {
|
||||
title = userMessage.slice(0, 57) + (userMessage.length > 57 ? '...' : '')
|
||||
}
|
||||
}
|
||||
|
||||
await this.updateSession(sessionId, { title })
|
||||
logger.info(`[ChatService] Generated title for session ${sessionId}: "${title}"`)
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[ChatService] Failed to generate title for session ${sessionId}: ${error instanceof Error ? error.message : error}`
|
||||
)
|
||||
// Fall back to truncated user message
|
||||
try {
|
||||
const fallbackTitle = userMessage.slice(0, 57) + (userMessage.length > 57 ? '...' : '')
|
||||
await this.updateSession(sessionId, { title: fallbackTitle })
|
||||
} catch {
|
||||
// Silently fail - session keeps "New Chat" title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAllSessions() {
|
||||
try {
|
||||
await ChatSession.query().delete()
|
||||
return { success: true, message: 'All chat sessions deleted' }
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[ChatService] Failed to delete all sessions: ${
|
||||
error instanceof Error ? error.message : error
|
||||
}`
|
||||
)
|
||||
throw new Error('Failed to delete all chat sessions')
|
||||
}
|
||||
}
|
||||
}
|
||||
317
admin/app/services/collection_manifest_service.ts
Normal file
317
admin/app/services/collection_manifest_service.ts
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
import axios from 'axios'
|
||||
import vine from '@vinejs/vine'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import { DateTime } from 'luxon'
|
||||
import { join } from 'path'
|
||||
import CollectionManifest from '#models/collection_manifest'
|
||||
import InstalledResource from '#models/installed_resource'
|
||||
import { zimCategoriesSpecSchema, mapsSpecSchema, wikipediaSpecSchema } from '#validators/curated_collections'
|
||||
import {
|
||||
ensureDirectoryExists,
|
||||
listDirectoryContents,
|
||||
getFileStatsIfExists,
|
||||
ZIM_STORAGE_PATH,
|
||||
} from '../utils/fs.js'
|
||||
import type {
|
||||
ManifestType,
|
||||
ZimCategoriesSpec,
|
||||
MapsSpec,
|
||||
CategoryWithStatus,
|
||||
CollectionWithStatus,
|
||||
SpecResource,
|
||||
SpecTier,
|
||||
} from '../../types/collections.js'
|
||||
|
||||
const SPEC_URLS: Record<ManifestType, string> = {
|
||||
zim_categories: 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/kiwix-categories.json',
|
||||
maps: 'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/main/collections/maps.json',
|
||||
wikipedia: 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/wikipedia.json',
|
||||
}
|
||||
|
||||
const VALIDATORS: Record<ManifestType, any> = {
|
||||
zim_categories: zimCategoriesSpecSchema,
|
||||
maps: mapsSpecSchema,
|
||||
wikipedia: wikipediaSpecSchema,
|
||||
}
|
||||
|
||||
export class CollectionManifestService {
|
||||
private readonly mapStoragePath = '/storage/maps'
|
||||
|
||||
// ---- Spec management ----
|
||||
|
||||
async fetchAndCacheSpec(type: ManifestType): Promise<boolean> {
|
||||
try {
|
||||
const response = await axios.get(SPEC_URLS[type], { timeout: 15000 })
|
||||
|
||||
const validated = await vine.validate({
|
||||
schema: VALIDATORS[type],
|
||||
data: response.data,
|
||||
})
|
||||
|
||||
const existing = await CollectionManifest.find(type)
|
||||
const specVersion = validated.spec_version
|
||||
|
||||
if (existing) {
|
||||
const changed = existing.spec_version !== specVersion
|
||||
existing.spec_version = specVersion
|
||||
existing.spec_data = validated
|
||||
existing.fetched_at = DateTime.now()
|
||||
await existing.save()
|
||||
return changed
|
||||
}
|
||||
|
||||
await CollectionManifest.create({
|
||||
type,
|
||||
spec_version: specVersion,
|
||||
spec_data: validated,
|
||||
fetched_at: DateTime.now(),
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error(`[CollectionManifestService] Failed to fetch spec for ${type}:`, error?.message || error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async getCachedSpec<T>(type: ManifestType): Promise<T | null> {
|
||||
const manifest = await CollectionManifest.find(type)
|
||||
if (!manifest) return null
|
||||
return manifest.spec_data as T
|
||||
}
|
||||
|
||||
async getSpecWithFallback<T>(type: ManifestType): Promise<T | null> {
|
||||
try {
|
||||
await this.fetchAndCacheSpec(type)
|
||||
} catch {
|
||||
// Fetch failed, will fall back to cache
|
||||
}
|
||||
return this.getCachedSpec<T>(type)
|
||||
}
|
||||
|
||||
// ---- Status computation ----
|
||||
|
||||
async getCategoriesWithStatus(): Promise<CategoryWithStatus[]> {
|
||||
const spec = await this.getSpecWithFallback<ZimCategoriesSpec>('zim_categories')
|
||||
if (!spec) return []
|
||||
|
||||
const installedResources = await InstalledResource.query().where('resource_type', 'zim')
|
||||
const installedMap = new Map(installedResources.map((r) => [r.resource_id, r]))
|
||||
|
||||
return spec.categories.map((category) => ({
|
||||
...category,
|
||||
installedTierSlug: this.getInstalledTierForCategory(category.tiers, installedMap),
|
||||
}))
|
||||
}
|
||||
|
||||
async getMapCollectionsWithStatus(): Promise<CollectionWithStatus[]> {
|
||||
const spec = await this.getSpecWithFallback<MapsSpec>('maps')
|
||||
if (!spec) return []
|
||||
|
||||
const installedResources = await InstalledResource.query().where('resource_type', 'map')
|
||||
const installedIds = new Set(installedResources.map((r) => r.resource_id))
|
||||
|
||||
return spec.collections.map((collection) => {
|
||||
const installedCount = collection.resources.filter((r) => installedIds.has(r.id)).length
|
||||
return {
|
||||
...collection,
|
||||
all_installed: installedCount === collection.resources.length,
|
||||
installed_count: installedCount,
|
||||
total_count: collection.resources.length,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Tier resolution ----
|
||||
|
||||
static resolveTierResources(tier: SpecTier, allTiers: SpecTier[]): SpecResource[] {
|
||||
const visited = new Set<string>()
|
||||
return CollectionManifestService._resolveTierResourcesInner(tier, allTiers, visited)
|
||||
}
|
||||
|
||||
private static _resolveTierResourcesInner(
|
||||
tier: SpecTier,
|
||||
allTiers: SpecTier[],
|
||||
visited: Set<string>
|
||||
): SpecResource[] {
|
||||
if (visited.has(tier.slug)) return [] // cycle detection
|
||||
visited.add(tier.slug)
|
||||
|
||||
const resources: SpecResource[] = []
|
||||
|
||||
if (tier.includesTier) {
|
||||
const included = allTiers.find((t) => t.slug === tier.includesTier)
|
||||
if (included) {
|
||||
resources.push(...CollectionManifestService._resolveTierResourcesInner(included, allTiers, visited))
|
||||
}
|
||||
}
|
||||
|
||||
resources.push(...tier.resources)
|
||||
return resources
|
||||
}
|
||||
|
||||
getInstalledTierForCategory(
|
||||
tiers: SpecTier[],
|
||||
installedMap: Map<string, InstalledResource>
|
||||
): string | undefined {
|
||||
// Check from highest tier to lowest (tiers are ordered low to high in spec)
|
||||
const reversedTiers = [...tiers].reverse()
|
||||
|
||||
for (const tier of reversedTiers) {
|
||||
const resolved = CollectionManifestService.resolveTierResources(tier, tiers)
|
||||
if (resolved.length === 0) continue
|
||||
|
||||
const allInstalled = resolved.every((r) => installedMap.has(r.id))
|
||||
if (allInstalled) {
|
||||
return tier.slug
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ---- Filename parsing ----
|
||||
|
||||
static parseZimFilename(filename: string): { resource_id: string; version: string } | null {
|
||||
const name = filename.replace(/\.zim$/, '')
|
||||
const match = name.match(/^(.+)_(\d{4}-\d{2})$/)
|
||||
if (!match) return null
|
||||
return { resource_id: match[1], version: match[2] }
|
||||
}
|
||||
|
||||
static parseMapFilename(filename: string): { resource_id: string; version: string } | null {
|
||||
const name = filename.replace(/\.pmtiles$/, '')
|
||||
const match = name.match(/^(.+)_(\d{4}-\d{2})$/)
|
||||
if (!match) return null
|
||||
return { resource_id: match[1], version: match[2] }
|
||||
}
|
||||
|
||||
// ---- Filesystem reconciliation ----
|
||||
|
||||
async reconcileFromFilesystem(): Promise<{ zim: number; map: number }> {
|
||||
let zimCount = 0
|
||||
let mapCount = 0
|
||||
|
||||
console.log("RECONCILING FILESYSTEM MANIFESTS...")
|
||||
|
||||
// Reconcile ZIM files
|
||||
try {
|
||||
const zimDir = join(process.cwd(), ZIM_STORAGE_PATH)
|
||||
await ensureDirectoryExists(zimDir)
|
||||
const zimItems = await listDirectoryContents(zimDir)
|
||||
const zimFiles = zimItems.filter((f) => f.name.endsWith('.zim'))
|
||||
|
||||
console.log(`Found ${zimFiles.length} ZIM files on disk. Reconciling with database...`)
|
||||
|
||||
// Get spec for URL lookup
|
||||
const zimSpec = await this.getCachedSpec<ZimCategoriesSpec>('zim_categories')
|
||||
const specResourceMap = new Map<string, SpecResource>()
|
||||
if (zimSpec) {
|
||||
for (const cat of zimSpec.categories) {
|
||||
for (const tier of cat.tiers) {
|
||||
for (const res of tier.resources) {
|
||||
specResourceMap.set(res.id, res)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const seenZimIds = new Set<string>()
|
||||
|
||||
for (const file of zimFiles) {
|
||||
console.log(`Processing ZIM file: ${file.name}`)
|
||||
// Skip Wikipedia files (managed by WikipediaSelection model)
|
||||
if (file.name.startsWith('wikipedia_en_')) continue
|
||||
|
||||
const parsed = CollectionManifestService.parseZimFilename(file.name)
|
||||
console.log(`Parsed ZIM filename:`, parsed)
|
||||
if (!parsed) continue
|
||||
|
||||
seenZimIds.add(parsed.resource_id)
|
||||
|
||||
const specRes = specResourceMap.get(parsed.resource_id)
|
||||
const filePath = join(zimDir, file.name)
|
||||
const stats = await getFileStatsIfExists(filePath)
|
||||
|
||||
await InstalledResource.updateOrCreate(
|
||||
{ resource_id: parsed.resource_id, resource_type: 'zim' },
|
||||
{
|
||||
version: parsed.version,
|
||||
url: specRes?.url || '',
|
||||
file_path: filePath,
|
||||
file_size_bytes: stats ? Number(stats.size) : null,
|
||||
installed_at: DateTime.now(),
|
||||
}
|
||||
)
|
||||
zimCount++
|
||||
}
|
||||
|
||||
// Remove entries for ZIM files no longer on disk
|
||||
const existingZim = await InstalledResource.query().where('resource_type', 'zim')
|
||||
for (const entry of existingZim) {
|
||||
if (!seenZimIds.has(entry.resource_id)) {
|
||||
await entry.delete()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[CollectionManifestService] Error reconciling ZIM files:', error)
|
||||
}
|
||||
|
||||
// Reconcile map files
|
||||
try {
|
||||
const mapDir = join(process.cwd(), this.mapStoragePath, 'pmtiles')
|
||||
await ensureDirectoryExists(mapDir)
|
||||
const mapItems = await listDirectoryContents(mapDir)
|
||||
const mapFiles = mapItems.filter((f) => f.name.endsWith('.pmtiles'))
|
||||
|
||||
// Get spec for URL/version lookup
|
||||
const mapSpec = await this.getCachedSpec<MapsSpec>('maps')
|
||||
const mapResourceMap = new Map<string, SpecResource>()
|
||||
if (mapSpec) {
|
||||
for (const col of mapSpec.collections) {
|
||||
for (const res of col.resources) {
|
||||
mapResourceMap.set(res.id, res)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const seenMapIds = new Set<string>()
|
||||
|
||||
for (const file of mapFiles) {
|
||||
const parsed = CollectionManifestService.parseMapFilename(file.name)
|
||||
if (!parsed) continue
|
||||
|
||||
seenMapIds.add(parsed.resource_id)
|
||||
|
||||
const specRes = mapResourceMap.get(parsed.resource_id)
|
||||
const filePath = join(mapDir, file.name)
|
||||
const stats = await getFileStatsIfExists(filePath)
|
||||
|
||||
await InstalledResource.updateOrCreate(
|
||||
{ resource_id: parsed.resource_id, resource_type: 'map' },
|
||||
{
|
||||
version: parsed.version,
|
||||
url: specRes?.url || '',
|
||||
file_path: filePath,
|
||||
file_size_bytes: stats ? Number(stats.size) : null,
|
||||
installed_at: DateTime.now(),
|
||||
}
|
||||
)
|
||||
mapCount++
|
||||
}
|
||||
|
||||
// Remove entries for map files no longer on disk
|
||||
const existingMaps = await InstalledResource.query().where('resource_type', 'map')
|
||||
for (const entry of existingMaps) {
|
||||
if (!seenMapIds.has(entry.resource_id)) {
|
||||
await entry.delete()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[CollectionManifestService] Error reconciling map files:', error)
|
||||
}
|
||||
|
||||
logger.info(`[CollectionManifestService] Reconciled ${zimCount} ZIM files, ${mapCount} map files`)
|
||||
return { zim: zimCount, map: mapCount }
|
||||
}
|
||||
}
|
||||
157
admin/app/services/collection_update_service.ts
Normal file
157
admin/app/services/collection_update_service.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import logger from '@adonisjs/core/services/logger'
|
||||
import env from '#start/env'
|
||||
import axios from 'axios'
|
||||
import InstalledResource from '#models/installed_resource'
|
||||
import { RunDownloadJob } from '../jobs/run_download_job.js'
|
||||
import { ZIM_STORAGE_PATH } from '../utils/fs.js'
|
||||
import { join } from 'path'
|
||||
import type {
|
||||
ResourceUpdateCheckRequest,
|
||||
ResourceUpdateInfo,
|
||||
ContentUpdateCheckResult,
|
||||
} from '../../types/collections.js'
|
||||
import { NOMAD_API_DEFAULT_BASE_URL } from '../../constants/misc.js'
|
||||
|
||||
const MAP_STORAGE_PATH = '/storage/maps'
|
||||
|
||||
const ZIM_MIME_TYPES = ['application/x-zim', 'application/x-openzim', 'application/octet-stream']
|
||||
const PMTILES_MIME_TYPES = ['application/vnd.pmtiles', 'application/octet-stream']
|
||||
|
||||
export class CollectionUpdateService {
|
||||
async checkForUpdates(): Promise<ContentUpdateCheckResult> {
|
||||
const nomadAPIURL = env.get('NOMAD_API_URL') || NOMAD_API_DEFAULT_BASE_URL
|
||||
if (!nomadAPIURL) {
|
||||
return {
|
||||
updates: [],
|
||||
checked_at: new Date().toISOString(),
|
||||
error: 'Nomad API is not configured. Set the NOMAD_API_URL environment variable.',
|
||||
}
|
||||
}
|
||||
|
||||
const installed = await InstalledResource.all()
|
||||
if (installed.length === 0) {
|
||||
return {
|
||||
updates: [],
|
||||
checked_at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
const requestBody: ResourceUpdateCheckRequest = {
|
||||
resources: installed.map((r) => ({
|
||||
resource_id: r.resource_id,
|
||||
resource_type: r.resource_type,
|
||||
installed_version: r.version,
|
||||
})),
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post<ResourceUpdateInfo[]>(`${nomadAPIURL}/api/v1/resources/check-updates`, requestBody, {
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
logger.info(
|
||||
`[CollectionUpdateService] Update check complete: ${response.data.length} update(s) available`
|
||||
)
|
||||
|
||||
return {
|
||||
updates: response.data,
|
||||
checked_at: new Date().toISOString(),
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
logger.error(
|
||||
`[CollectionUpdateService] Nomad API returned ${error.response.status}: ${JSON.stringify(error.response.data)}`
|
||||
)
|
||||
return {
|
||||
updates: [],
|
||||
checked_at: new Date().toISOString(),
|
||||
error: `Nomad API returned status ${error.response.status}`,
|
||||
}
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Unknown error contacting Nomad API'
|
||||
logger.error(`[CollectionUpdateService] Failed to check for updates: ${message}`)
|
||||
return {
|
||||
updates: [],
|
||||
checked_at: new Date().toISOString(),
|
||||
error: `Failed to contact Nomad API: ${message}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async applyUpdate(
|
||||
update: ResourceUpdateInfo
|
||||
): Promise<{ success: boolean; jobId?: string; error?: string }> {
|
||||
// Check if a download is already in progress for this URL
|
||||
const existingJob = await RunDownloadJob.getByUrl(update.download_url)
|
||||
if (existingJob) {
|
||||
const state = await existingJob.getState()
|
||||
if (state === 'active' || state === 'waiting' || state === 'delayed') {
|
||||
return {
|
||||
success: false,
|
||||
error: `A download is already in progress for ${update.resource_id}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const filename = this.buildFilename(update)
|
||||
const filepath = this.buildFilepath(update, filename)
|
||||
|
||||
const result = await RunDownloadJob.dispatch({
|
||||
url: update.download_url,
|
||||
filepath,
|
||||
timeout: 30000,
|
||||
allowedMimeTypes:
|
||||
update.resource_type === 'zim' ? ZIM_MIME_TYPES : PMTILES_MIME_TYPES,
|
||||
forceNew: true,
|
||||
filetype: update.resource_type,
|
||||
resourceMetadata: {
|
||||
resource_id: update.resource_id,
|
||||
version: update.latest_version,
|
||||
collection_ref: null,
|
||||
},
|
||||
})
|
||||
|
||||
if (!result || !result.job) {
|
||||
return { success: false, error: 'Failed to dispatch download job' }
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[CollectionUpdateService] Dispatched update download for ${update.resource_id}: ${update.installed_version} → ${update.latest_version}`
|
||||
)
|
||||
|
||||
return { success: true, jobId: result.job.id }
|
||||
}
|
||||
|
||||
async applyAllUpdates(
|
||||
updates: ResourceUpdateInfo[]
|
||||
): Promise<{ results: Array<{ resource_id: string; success: boolean; jobId?: string; error?: string }> }> {
|
||||
const results: Array<{
|
||||
resource_id: string
|
||||
success: boolean
|
||||
jobId?: string
|
||||
error?: string
|
||||
}> = []
|
||||
|
||||
for (const update of updates) {
|
||||
const result = await this.applyUpdate(update)
|
||||
results.push({ resource_id: update.resource_id, ...result })
|
||||
}
|
||||
|
||||
return { results }
|
||||
}
|
||||
|
||||
private buildFilename(update: ResourceUpdateInfo): string {
|
||||
if (update.resource_type === 'zim') {
|
||||
return `${update.resource_id}_${update.latest_version}.zim`
|
||||
}
|
||||
return `${update.resource_id}_${update.latest_version}.pmtiles`
|
||||
}
|
||||
|
||||
private buildFilepath(update: ResourceUpdateInfo, filename: string): string {
|
||||
if (update.resource_type === 'zim') {
|
||||
return join(process.cwd(), ZIM_STORAGE_PATH, filename)
|
||||
}
|
||||
return join(process.cwd(), MAP_STORAGE_PATH, 'pmtiles', filename)
|
||||
}
|
||||
}
|
||||
484
admin/app/services/container_registry_service.ts
Normal file
484
admin/app/services/container_registry_service.ts
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
import logger from '@adonisjs/core/services/logger'
|
||||
import { isNewerVersion, parseMajorVersion } from '../utils/version.js'
|
||||
|
||||
export interface ParsedImageReference {
|
||||
registry: string
|
||||
namespace: string
|
||||
repo: string
|
||||
tag: string
|
||||
/** Full name for registry API calls: namespace/repo */
|
||||
fullName: string
|
||||
}
|
||||
|
||||
export interface AvailableUpdate {
|
||||
tag: string
|
||||
isLatest: boolean
|
||||
releaseUrl?: string
|
||||
}
|
||||
|
||||
interface TokenCacheEntry {
|
||||
token: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
const SEMVER_TAG_PATTERN = /^v?(\d+\.\d+(?:\.\d+)?)$/
|
||||
const PLATFORM_SUFFIXES = ['-arm64', '-amd64', '-alpine', '-slim', '-cuda', '-rocm']
|
||||
const REJECTED_TAGS = new Set(['latest', 'nightly', 'edge', 'dev', 'beta', 'alpha', 'canary', 'rc', 'test', 'debug'])
|
||||
|
||||
export class ContainerRegistryService {
|
||||
private tokenCache = new Map<string, TokenCacheEntry>()
|
||||
private sourceUrlCache = new Map<string, string | null>()
|
||||
private releaseTagPrefixCache = new Map<string, string>()
|
||||
|
||||
/**
|
||||
* Parse a Docker image reference string into its components.
|
||||
*/
|
||||
parseImageReference(image: string): ParsedImageReference {
|
||||
let registry: string
|
||||
let remainder: string
|
||||
let tag = 'latest'
|
||||
|
||||
// Split off the tag
|
||||
const lastColon = image.lastIndexOf(':')
|
||||
if (lastColon > -1 && !image.substring(lastColon).includes('/')) {
|
||||
tag = image.substring(lastColon + 1)
|
||||
image = image.substring(0, lastColon)
|
||||
}
|
||||
|
||||
// Determine registry vs image path
|
||||
const parts = image.split('/')
|
||||
|
||||
if (parts.length === 1) {
|
||||
// e.g. "nginx" → Docker Hub library image
|
||||
registry = 'registry-1.docker.io'
|
||||
remainder = `library/${parts[0]}`
|
||||
} else if (parts.length === 2 && !parts[0].includes('.') && !parts[0].includes(':')) {
|
||||
// e.g. "ollama/ollama" → Docker Hub user image
|
||||
registry = 'registry-1.docker.io'
|
||||
remainder = image
|
||||
} else {
|
||||
// e.g. "ghcr.io/kiwix/kiwix-serve" → custom registry
|
||||
registry = parts[0]
|
||||
remainder = parts.slice(1).join('/')
|
||||
}
|
||||
|
||||
const namespaceParts = remainder.split('/')
|
||||
const repo = namespaceParts.pop()!
|
||||
const namespace = namespaceParts.join('/')
|
||||
|
||||
return {
|
||||
registry,
|
||||
namespace,
|
||||
repo,
|
||||
tag,
|
||||
fullName: remainder,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an anonymous auth token for the given registry and repository.
|
||||
* NOTE: This could be expanded in the future to support private repo authentication
|
||||
*/
|
||||
private async getToken(registry: string, fullName: string): Promise<string> {
|
||||
const cacheKey = `${registry}/${fullName}`
|
||||
const cached = this.tokenCache.get(cacheKey)
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.token
|
||||
}
|
||||
|
||||
let tokenUrl: string
|
||||
if (registry === 'registry-1.docker.io') {
|
||||
tokenUrl = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${fullName}:pull`
|
||||
} else if (registry === 'ghcr.io') {
|
||||
tokenUrl = `https://ghcr.io/token?service=ghcr.io&scope=repository:${fullName}:pull`
|
||||
} else {
|
||||
// For other registries, try the standard v2 token endpoint
|
||||
tokenUrl = `https://${registry}/token?service=${registry}&scope=repository:${fullName}:pull`
|
||||
}
|
||||
|
||||
const response = await this.fetchWithRetry(tokenUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get auth token from ${registry}: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { token?: string; access_token?: string }
|
||||
const token = data.token || data.access_token || ''
|
||||
|
||||
if (!token) {
|
||||
throw new Error(`No token returned from ${registry}`)
|
||||
}
|
||||
|
||||
// Cache for 5 minutes (tokens usually last longer, but be conservative)
|
||||
this.tokenCache.set(cacheKey, {
|
||||
token,
|
||||
expiresAt: Date.now() + 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
/**
|
||||
* List all tags for a given image from the registry.
|
||||
*/
|
||||
async listTags(parsed: ParsedImageReference): Promise<string[]> {
|
||||
const token = await this.getToken(parsed.registry, parsed.fullName)
|
||||
const allTags: string[] = []
|
||||
let url = `https://${parsed.registry}/v2/${parsed.fullName}/tags/list?n=1000`
|
||||
|
||||
while (url) {
|
||||
const response = await this.fetchWithRetry(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to list tags for ${parsed.fullName}: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { tags?: string[] }
|
||||
if (data.tags) {
|
||||
allTags.push(...data.tags)
|
||||
}
|
||||
|
||||
// Handle pagination via Link header
|
||||
const linkHeader = response.headers.get('link')
|
||||
if (linkHeader) {
|
||||
const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/)
|
||||
url = match ? match[1] : ''
|
||||
} else {
|
||||
url = ''
|
||||
}
|
||||
}
|
||||
|
||||
return allTags
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific tag supports the given architecture by fetching its manifest.
|
||||
*/
|
||||
async checkArchSupport(parsed: ParsedImageReference, tag: string, hostArch: string): Promise<boolean> {
|
||||
try {
|
||||
const token = await this.getToken(parsed.registry, parsed.fullName)
|
||||
const url = `https://${parsed.registry}/v2/${parsed.fullName}/manifests/${tag}`
|
||||
|
||||
const response = await this.fetchWithRetry(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: [
|
||||
'application/vnd.oci.image.index.v1+json',
|
||||
'application/vnd.docker.distribution.manifest.list.v2+json',
|
||||
'application/vnd.oci.image.manifest.v1+json',
|
||||
'application/vnd.docker.distribution.manifest.v2+json',
|
||||
].join(', '),
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) return true // If we can't check, assume it's compatible
|
||||
|
||||
const manifest = (await response.json()) as {
|
||||
mediaType?: string
|
||||
manifests?: Array<{ platform?: { architecture?: string } }>
|
||||
}
|
||||
const mediaType = manifest.mediaType || response.headers.get('content-type') || ''
|
||||
|
||||
// Manifest list — check if any platform matches
|
||||
if (
|
||||
mediaType.includes('manifest.list') ||
|
||||
mediaType.includes('image.index') ||
|
||||
manifest.manifests
|
||||
) {
|
||||
const manifests = manifest.manifests || []
|
||||
return manifests.some(
|
||||
(m: any) => m.platform && m.platform.architecture === hostArch
|
||||
)
|
||||
}
|
||||
|
||||
// Single manifest — assume compatible (can't easily determine arch without fetching config blob)
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.warn(`[ContainerRegistryService] Error checking arch for ${tag}: ${error.message}`)
|
||||
return true // Assume compatible on error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the source repository URL from an image's OCI labels.
|
||||
* Uses the standardized `org.opencontainers.image.source` label.
|
||||
* Result is cached per image (not per tag).
|
||||
*/
|
||||
async getSourceUrl(parsed: ParsedImageReference): Promise<string | null> {
|
||||
const cacheKey = `${parsed.registry}/${parsed.fullName}`
|
||||
if (this.sourceUrlCache.has(cacheKey)) {
|
||||
return this.sourceUrlCache.get(cacheKey)!
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await this.getToken(parsed.registry, parsed.fullName)
|
||||
|
||||
// First get the manifest to find the config blob digest
|
||||
const manifestUrl = `https://${parsed.registry}/v2/${parsed.fullName}/manifests/${parsed.tag}`
|
||||
const manifestRes = await this.fetchWithRetry(manifestUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: [
|
||||
'application/vnd.oci.image.manifest.v1+json',
|
||||
'application/vnd.docker.distribution.manifest.v2+json',
|
||||
'application/vnd.oci.image.index.v1+json',
|
||||
'application/vnd.docker.distribution.manifest.list.v2+json',
|
||||
].join(', '),
|
||||
},
|
||||
})
|
||||
|
||||
if (!manifestRes.ok) {
|
||||
this.sourceUrlCache.set(cacheKey, null)
|
||||
return null
|
||||
}
|
||||
|
||||
const manifest = (await manifestRes.json()) as {
|
||||
config?: { digest?: string }
|
||||
manifests?: Array<{ digest?: string; mediaType?: string; platform?: { architecture?: string } }>
|
||||
}
|
||||
|
||||
// If this is a manifest list, pick the first manifest to get the config
|
||||
let configDigest = manifest.config?.digest
|
||||
if (!configDigest && manifest.manifests?.length) {
|
||||
const firstManifest = manifest.manifests[0]
|
||||
if (firstManifest.digest) {
|
||||
const childRes = await this.fetchWithRetry(
|
||||
`https://${parsed.registry}/v2/${parsed.fullName}/manifests/${firstManifest.digest}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json',
|
||||
},
|
||||
}
|
||||
)
|
||||
if (childRes.ok) {
|
||||
const childManifest = (await childRes.json()) as { config?: { digest?: string } }
|
||||
configDigest = childManifest.config?.digest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!configDigest) {
|
||||
this.sourceUrlCache.set(cacheKey, null)
|
||||
return null
|
||||
}
|
||||
|
||||
// Fetch the config blob to read labels
|
||||
const blobUrl = `https://${parsed.registry}/v2/${parsed.fullName}/blobs/${configDigest}`
|
||||
const blobRes = await this.fetchWithRetry(blobUrl, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
|
||||
if (!blobRes.ok) {
|
||||
this.sourceUrlCache.set(cacheKey, null)
|
||||
return null
|
||||
}
|
||||
|
||||
const config = (await blobRes.json()) as {
|
||||
config?: { Labels?: Record<string, string> }
|
||||
}
|
||||
|
||||
const sourceUrl = config.config?.Labels?.['org.opencontainers.image.source'] || null
|
||||
this.sourceUrlCache.set(cacheKey, sourceUrl)
|
||||
return sourceUrl
|
||||
} catch (error) {
|
||||
logger.warn(`[ContainerRegistryService] Failed to get source URL for ${cacheKey}: ${error.message}`)
|
||||
this.sourceUrlCache.set(cacheKey, null)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether a GitHub/GitLab repo uses a 'v' prefix on release tags.
|
||||
* Probes the GitHub API with the current tag to determine the convention,
|
||||
* then caches the result per source URL.
|
||||
*/
|
||||
async detectReleaseTagPrefix(sourceUrl: string, sampleTag: string): Promise<string> {
|
||||
if (this.releaseTagPrefixCache.has(sourceUrl)) {
|
||||
return this.releaseTagPrefixCache.get(sourceUrl)!
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(sourceUrl)
|
||||
if (url.hostname !== 'github.com') {
|
||||
this.releaseTagPrefixCache.set(sourceUrl, '')
|
||||
return ''
|
||||
}
|
||||
|
||||
const cleanPath = url.pathname.replace(/\.git$/, '').replace(/\/$/, '')
|
||||
const strippedTag = sampleTag.replace(/^v/, '')
|
||||
const vTag = `v${strippedTag}`
|
||||
|
||||
// Try both variants against GitHub's API — the one that 200s tells us the convention
|
||||
// Try v-prefixed first since it's more common
|
||||
const vRes = await this.fetchWithRetry(
|
||||
`https://api.github.com/repos${cleanPath}/releases/tags/${vTag}`,
|
||||
{ headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'ProjectNomad' } },
|
||||
1
|
||||
)
|
||||
if (vRes.ok) {
|
||||
this.releaseTagPrefixCache.set(sourceUrl, 'v')
|
||||
return 'v'
|
||||
}
|
||||
|
||||
const plainRes = await this.fetchWithRetry(
|
||||
`https://api.github.com/repos${cleanPath}/releases/tags/${strippedTag}`,
|
||||
{ headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'ProjectNomad' } },
|
||||
1
|
||||
)
|
||||
if (plainRes.ok) {
|
||||
this.releaseTagPrefixCache.set(sourceUrl, '')
|
||||
return ''
|
||||
}
|
||||
} catch {
|
||||
// On error, fall through to default
|
||||
}
|
||||
|
||||
// Default: no prefix modification
|
||||
this.releaseTagPrefixCache.set(sourceUrl, '')
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a release URL for a specific tag given a source repository URL and
|
||||
* the detected release tag prefix convention.
|
||||
* Supports GitHub and GitLab URL patterns.
|
||||
*/
|
||||
buildReleaseUrl(sourceUrl: string, tag: string, releaseTagPrefix: string): string | undefined {
|
||||
try {
|
||||
const url = new URL(sourceUrl)
|
||||
if (url.hostname === 'github.com' || url.hostname.includes('gitlab')) {
|
||||
const cleanPath = url.pathname.replace(/\.git$/, '').replace(/\/$/, '')
|
||||
const strippedTag = tag.replace(/^v/, '')
|
||||
const releaseTag = releaseTagPrefix ? `${releaseTagPrefix}${strippedTag}` : strippedTag
|
||||
return `${url.origin}${cleanPath}/releases/tag/${releaseTag}`
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL, skip
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter and sort tags to find compatible updates for a service.
|
||||
*/
|
||||
filterCompatibleUpdates(
|
||||
tags: string[],
|
||||
currentTag: string,
|
||||
majorVersion: number
|
||||
): string[] {
|
||||
return tags
|
||||
.filter((tag) => {
|
||||
// Must match semver pattern
|
||||
if (!SEMVER_TAG_PATTERN.test(tag)) return false
|
||||
|
||||
// Reject known non-version tags
|
||||
if (REJECTED_TAGS.has(tag.toLowerCase())) return false
|
||||
|
||||
// Reject platform suffixes
|
||||
if (PLATFORM_SUFFIXES.some((suffix) => tag.toLowerCase().endsWith(suffix))) return false
|
||||
|
||||
// Must be same major version
|
||||
if (parseMajorVersion(tag) !== majorVersion) return false
|
||||
|
||||
// Must be newer than current
|
||||
return isNewerVersion(tag, currentTag)
|
||||
})
|
||||
.sort((a, b) => (isNewerVersion(a, b) ? -1 : 1)) // Newest first
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level method to get available updates for a service.
|
||||
* Returns a sorted list of compatible newer versions (newest first).
|
||||
*/
|
||||
async getAvailableUpdates(
|
||||
containerImage: string,
|
||||
hostArch: string,
|
||||
fallbackSourceRepo?: string | null
|
||||
): Promise<AvailableUpdate[]> {
|
||||
const parsed = this.parseImageReference(containerImage)
|
||||
const currentTag = parsed.tag
|
||||
|
||||
if (currentTag === 'latest') {
|
||||
logger.warn(
|
||||
`[ContainerRegistryService] Cannot check updates for ${containerImage} — using :latest tag`
|
||||
)
|
||||
return []
|
||||
}
|
||||
|
||||
const majorVersion = parseMajorVersion(currentTag)
|
||||
|
||||
// Fetch tags and source URL in parallel
|
||||
const [tags, ociSourceUrl] = await Promise.all([
|
||||
this.listTags(parsed),
|
||||
this.getSourceUrl(parsed),
|
||||
])
|
||||
|
||||
// OCI label takes precedence, fall back to DB-stored source_repo
|
||||
const sourceUrl = ociSourceUrl || fallbackSourceRepo || null
|
||||
|
||||
const compatible = this.filterCompatibleUpdates(tags, currentTag, majorVersion)
|
||||
|
||||
// Detect release tag prefix convention (e.g. 'v' vs no prefix) if we have a source URL
|
||||
let releaseTagPrefix = ''
|
||||
if (sourceUrl) {
|
||||
releaseTagPrefix = await this.detectReleaseTagPrefix(sourceUrl, currentTag)
|
||||
}
|
||||
|
||||
// Check architecture support for the top candidates (limit checks to save API calls)
|
||||
const maxArchChecks = 10
|
||||
const results: AvailableUpdate[] = []
|
||||
|
||||
for (const tag of compatible.slice(0, maxArchChecks)) {
|
||||
const supported = await this.checkArchSupport(parsed, tag, hostArch)
|
||||
if (supported) {
|
||||
results.push({
|
||||
tag,
|
||||
isLatest: results.length === 0,
|
||||
releaseUrl: sourceUrl ? this.buildReleaseUrl(sourceUrl, tag, releaseTagPrefix) : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// For remaining tags (beyond arch check limit), include them but mark as not latest
|
||||
for (const tag of compatible.slice(maxArchChecks)) {
|
||||
results.push({
|
||||
tag,
|
||||
isLatest: false,
|
||||
releaseUrl: sourceUrl ? this.buildReleaseUrl(sourceUrl, tag, releaseTagPrefix) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch with retry and exponential backoff for rate limiting.
|
||||
*/
|
||||
private async fetchWithRetry(
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
maxRetries = 3
|
||||
): Promise<Response> {
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
const response = await fetch(url, init)
|
||||
|
||||
if (response.status === 429 && attempt < maxRetries) {
|
||||
const retryAfter = response.headers.get('retry-after')
|
||||
const delay = retryAfter
|
||||
? parseInt(retryAfter, 10) * 1000
|
||||
: Math.pow(2, attempt) * 1000
|
||||
logger.warn(
|
||||
`[ContainerRegistryService] Rate limited on ${url}, retrying in ${delay}ms`
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
continue
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
throw new Error(`Failed to fetch ${url} after ${maxRetries} retries`)
|
||||
}
|
||||
}
|
||||
|
|
@ -6,17 +6,18 @@ 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 { 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'
|
||||
|
||||
@inject()
|
||||
export class DockerService {
|
||||
private docker: Docker
|
||||
public docker: Docker
|
||||
private activeInstallations: Set<string> = new Set()
|
||||
public static KIWIX_SERVICE_NAME = 'nomad_kiwix_serve'
|
||||
public static OLLAMA_SERVICE_NAME = 'nomad_ollama'
|
||||
public static OPEN_WEBUI_SERVICE_NAME = 'nomad_open_webui'
|
||||
public static CYBERCHEF_SERVICE_NAME = 'nomad_cyberchef'
|
||||
public static FLATNOTES_SERVICE_NAME = 'nomad_flatnotes'
|
||||
public static KOLIBRI_SERVICE_NAME = 'nomad_kolibri'
|
||||
public static NOMAD_NETWORK = 'project-nomad_default'
|
||||
|
||||
constructor() {
|
||||
// Support both Linux (production) and Windows (development with Docker Desktop)
|
||||
|
|
@ -112,8 +113,8 @@ export class DockerService {
|
|||
const containers = await this.docker.listContainers({ all: true })
|
||||
const containerMap = new Map<string, Docker.ContainerInfo>()
|
||||
containers.forEach((container) => {
|
||||
const name = container.Names[0].replace('/', '')
|
||||
if (name.startsWith('nomad_')) {
|
||||
const name = container.Names[0]?.replace('/', '')
|
||||
if (name && name.startsWith('nomad_')) {
|
||||
containerMap.set(name, container)
|
||||
}
|
||||
})
|
||||
|
|
@ -123,11 +124,58 @@ export class DockerService {
|
|||
status: container.State,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error(`Error fetching services status: ${error.message}`)
|
||||
logger.error(`Error fetching services status: ${error.message}`)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL to access a service based on its configuration.
|
||||
* Attempts to return a docker-internal URL using the service name and exposed port.
|
||||
* @param serviceName - The name of the service to get the URL for.
|
||||
* @returns - The URL as a string, or null if it cannot be determined.
|
||||
*/
|
||||
async getServiceURL(serviceName: string): Promise<string | null> {
|
||||
if (!serviceName || serviceName.trim() === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
const service = await Service.query()
|
||||
.where('service_name', serviceName)
|
||||
.andWhere('installed', true)
|
||||
.first()
|
||||
|
||||
if (!service) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hostname = process.env.NODE_ENV === 'production' ? serviceName : 'localhost'
|
||||
|
||||
// First, check if ui_location is set and is a valid port number
|
||||
if (service.ui_location && parseInt(service.ui_location, 10)) {
|
||||
return `http://${hostname}:${service.ui_location}`
|
||||
}
|
||||
|
||||
// Next, try to extract a host port from container_config
|
||||
const parsedConfig = this._parseContainerConfig(service.container_config)
|
||||
if (parsedConfig?.HostConfig?.PortBindings) {
|
||||
const portBindings = parsedConfig.HostConfig.PortBindings
|
||||
const hostPorts = Object.values(portBindings)
|
||||
if (!hostPorts || !Array.isArray(hostPorts) || hostPorts.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hostPortsArray = hostPorts.flat() as { HostPort: string }[]
|
||||
const hostPortsStrings = hostPortsArray.map((binding) => binding.HostPort)
|
||||
if (hostPortsStrings.length > 0) {
|
||||
return `http://${hostname}:${hostPortsStrings[0]}`
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, return null if we can't determine a URL
|
||||
return null
|
||||
}
|
||||
|
||||
async createContainerPreflight(
|
||||
serviceName: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
|
|
@ -194,6 +242,136 @@ export class DockerService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force reinstall a service by stopping, removing, and recreating its container.
|
||||
* This method will also clear any associated volumes/data.
|
||||
* Handles edge cases gracefully (e.g., container not running, container not found).
|
||||
*/
|
||||
async forceReinstall(serviceName: string): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const service = await Service.query().where('service_name', serviceName).first()
|
||||
if (!service) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Service ${serviceName} not found`,
|
||||
}
|
||||
}
|
||||
|
||||
// Check if installation is already in progress
|
||||
if (this.activeInstallations.has(serviceName)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Service ${serviceName} installation is already in progress`,
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as installing to prevent concurrent operations
|
||||
this.activeInstallations.add(serviceName)
|
||||
service.installation_status = 'installing'
|
||||
await service.save()
|
||||
|
||||
this._broadcast(
|
||||
serviceName,
|
||||
'reinstall-starting',
|
||||
`Starting force reinstall for ${serviceName}...`
|
||||
)
|
||||
|
||||
// Step 1: Try to stop and remove the container if it exists
|
||||
try {
|
||||
const containers = await this.docker.listContainers({ all: true })
|
||||
const container = containers.find((c) => c.Names.includes(`/${serviceName}`))
|
||||
|
||||
if (container) {
|
||||
const dockerContainer = this.docker.getContainer(container.Id)
|
||||
|
||||
// Only try to stop if it's running
|
||||
if (container.State === 'running') {
|
||||
this._broadcast(serviceName, 'stopping', `Stopping container...`)
|
||||
await dockerContainer.stop({ t: 10 }).catch((error) => {
|
||||
// If already stopped, continue
|
||||
if (!error.message.includes('already stopped')) {
|
||||
logger.warn(`Error stopping container: ${error.message}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Step 2: Remove the container
|
||||
this._broadcast(serviceName, 'removing', `Removing container...`)
|
||||
await dockerContainer.remove({ force: true }).catch((error) => {
|
||||
logger.warn(`Error removing container: ${error.message}`)
|
||||
})
|
||||
} else {
|
||||
this._broadcast(
|
||||
serviceName,
|
||||
'no-container',
|
||||
`No existing container found, proceeding with installation...`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Error during container cleanup: ${error.message}`)
|
||||
this._broadcast(serviceName, 'cleanup-warning', `Warning during cleanup: ${error.message}`)
|
||||
}
|
||||
|
||||
// Step 3: Clear volumes/data if needed
|
||||
try {
|
||||
this._broadcast(serviceName, 'clearing-volumes', `Checking for volumes to clear...`)
|
||||
const volumes = await this.docker.listVolumes()
|
||||
const serviceVolumes =
|
||||
volumes.Volumes?.filter(
|
||||
(v) => v.Name.includes(serviceName) || v.Labels?.service === serviceName
|
||||
) || []
|
||||
|
||||
for (const vol of serviceVolumes) {
|
||||
try {
|
||||
const volume = this.docker.getVolume(vol.Name)
|
||||
await volume.remove({ force: true })
|
||||
this._broadcast(serviceName, 'volume-removed', `Removed volume: ${vol.Name}`)
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to remove volume ${vol.Name}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (serviceVolumes.length === 0) {
|
||||
this._broadcast(serviceName, 'no-volumes', `No volumes found to clear`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Error during volume cleanup: ${error.message}`)
|
||||
this._broadcast(
|
||||
serviceName,
|
||||
'volume-cleanup-warning',
|
||||
`Warning during volume cleanup: ${error.message}`
|
||||
)
|
||||
}
|
||||
|
||||
// Step 4: Mark service as uninstalled
|
||||
service.installed = false
|
||||
service.installation_status = 'installing'
|
||||
await service.save()
|
||||
|
||||
// Step 5: Recreate the container
|
||||
this._broadcast(serviceName, 'recreating', `Recreating container...`)
|
||||
const containerConfig = this._parseContainerConfig(service.container_config)
|
||||
|
||||
// Execute installation asynchronously and handle cleanup
|
||||
this._createContainer(service, containerConfig).catch(async (error) => {
|
||||
logger.error(`Reinstallation failed for ${serviceName}: ${error.message}`)
|
||||
await this._cleanupFailedInstallation(serviceName)
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Service ${serviceName} force reinstall initiated successfully. You can receive updates via server-sent events.`,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Force reinstall failed for ${serviceName}: ${error.message}`)
|
||||
await this._cleanupFailedInstallation(serviceName)
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to force reinstall service ${serviceName}: ${error.message}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the long-running process of creating a Docker container for a service.
|
||||
* NOTE: This method should not be called directly. Instead, use `createContainerPreflight` to check prerequisites first
|
||||
|
|
@ -244,16 +422,25 @@ export class DockerService {
|
|||
}
|
||||
}
|
||||
|
||||
// Start pulling the Docker image and wait for it to complete
|
||||
const pullStream = await this.docker.pull(service.container_image)
|
||||
this._broadcast(
|
||||
service.service_name,
|
||||
'pulling',
|
||||
`Pulling Docker image ${service.container_image}...`
|
||||
)
|
||||
await new Promise((res) => this.docker.modem.followProgress(pullStream, res))
|
||||
const imageExists = await this._checkImageExists(service.container_image)
|
||||
if (imageExists) {
|
||||
this._broadcast(
|
||||
service.service_name,
|
||||
'image-exists',
|
||||
`Docker image ${service.container_image} already exists locally. Skipping pull...`
|
||||
)
|
||||
} else {
|
||||
// Start pulling the Docker image and wait for it to complete
|
||||
const pullStream = await this.docker.pull(service.container_image)
|
||||
this._broadcast(
|
||||
service.service_name,
|
||||
'pulling',
|
||||
`Pulling Docker image ${service.container_image}...`
|
||||
)
|
||||
await new Promise((res) => this.docker.modem.followProgress(pullStream, res))
|
||||
}
|
||||
|
||||
if (service.service_name === DockerService.KIWIX_SERVICE_NAME) {
|
||||
if (service.service_name === SERVICE_NAMES.KIWIX) {
|
||||
await this._runPreinstallActions__KiwixServe()
|
||||
this._broadcast(
|
||||
service.service_name,
|
||||
|
|
@ -262,20 +449,79 @@ export class DockerService {
|
|||
)
|
||||
}
|
||||
|
||||
// GPU-aware configuration for Ollama
|
||||
let finalImage = service.container_image
|
||||
let gpuHostConfig = containerConfig?.HostConfig || {}
|
||||
|
||||
if (service.service_name === SERVICE_NAMES.OLLAMA) {
|
||||
const gpuResult = await this._detectGPUType()
|
||||
|
||||
if (gpuResult.type === 'nvidia') {
|
||||
this._broadcast(
|
||||
service.service_name,
|
||||
'gpu-config',
|
||||
`NVIDIA container runtime detected. Configuring container with GPU support...`
|
||||
)
|
||||
|
||||
// Add GPU support for NVIDIA
|
||||
gpuHostConfig = {
|
||||
...gpuHostConfig,
|
||||
DeviceRequests: [
|
||||
{
|
||||
Driver: 'nvidia',
|
||||
Count: -1, // -1 means all GPUs
|
||||
Capabilities: [['gpu']],
|
||||
},
|
||||
],
|
||||
}
|
||||
} else if (gpuResult.type === 'amd') {
|
||||
this._broadcast(
|
||||
service.service_name,
|
||||
'gpu-config',
|
||||
`AMD GPU detected. ROCm GPU acceleration is not yet supported in this version — proceeding with CPU-only configuration. GPU support for AMD will be available in a future update.`
|
||||
)
|
||||
logger.warn('[DockerService] AMD GPU detected but ROCm support is not yet enabled. Using CPU-only configuration.')
|
||||
// TODO: Re-enable AMD GPU support once ROCm image and device discovery are validated.
|
||||
// When re-enabling:
|
||||
// 1. Switch image to 'ollama/ollama:rocm'
|
||||
// 2. Restore _discoverAMDDevices() to map /dev/kfd and /dev/dri/* into the container
|
||||
} else if (gpuResult.toolkitMissing) {
|
||||
this._broadcast(
|
||||
service.service_name,
|
||||
'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(
|
||||
service.service_name,
|
||||
'gpu-config',
|
||||
`No GPU detected. Using CPU-only configuration...`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
this._broadcast(
|
||||
service.service_name,
|
||||
'creating',
|
||||
`Creating Docker container for service ${service.service_name}...`
|
||||
)
|
||||
const container = await this.docker.createContainer({
|
||||
Image: service.container_image,
|
||||
Image: finalImage,
|
||||
name: service.service_name,
|
||||
...(containerConfig?.User && { User: containerConfig.User }),
|
||||
...(containerConfig?.HostConfig && { HostConfig: containerConfig.HostConfig }),
|
||||
HostConfig: gpuHostConfig,
|
||||
...(containerConfig?.WorkingDir && { WorkingDir: containerConfig.WorkingDir }),
|
||||
...(containerConfig?.ExposedPorts && { ExposedPorts: containerConfig.ExposedPorts }),
|
||||
...(containerConfig?.Env && { Env: containerConfig.Env }),
|
||||
...(service.container_command ? { Cmd: service.container_command.split(' ') } : {}),
|
||||
// Ensure container is attached to the Nomad docker network in production
|
||||
...(process.env.NODE_ENV === 'production' && {
|
||||
NetworkingConfig: {
|
||||
EndpointsConfig: {
|
||||
[DockerService.NOMAD_NETWORK]: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
this._broadcast(
|
||||
|
|
@ -297,6 +543,22 @@ export class DockerService {
|
|||
// Remove from active installs tracking
|
||||
this.activeInstallations.delete(service.service_name)
|
||||
|
||||
// If Ollama was just installed, trigger Nomad docs discovery and embedding
|
||||
if (service.service_name === SERVICE_NAMES.OLLAMA) {
|
||||
logger.info('[DockerService] Ollama installation complete. Default behavior is to not enable chat suggestions.')
|
||||
await KVStore.setValue('chat.suggestionsEnabled', false)
|
||||
|
||||
logger.info('[DockerService] Ollama installation complete. Triggering Nomad docs discovery...')
|
||||
|
||||
// Need to use dynamic imports here to avoid circular dependency
|
||||
const ollamaService = new (await import('./ollama_service.js')).OllamaService()
|
||||
const ragService = new (await import('./rag_service.js')).RagService(this, ollamaService)
|
||||
|
||||
ragService.discoverNomadDocs().catch((error) => {
|
||||
logger.error('[DockerService] Failed to discover Nomad docs:', error)
|
||||
})
|
||||
}
|
||||
|
||||
this._broadcast(
|
||||
service.service_name,
|
||||
'completed',
|
||||
|
|
@ -335,8 +597,7 @@ export class DockerService {
|
|||
}
|
||||
|
||||
const dockerContainer = this.docker.getContainer(container.Id)
|
||||
await dockerContainer.stop()
|
||||
await dockerContainer.remove()
|
||||
await dockerContainer.remove({ force: true })
|
||||
|
||||
return { success: true, message: `Service ${serviceName} container removed successfully` }
|
||||
} catch (error) {
|
||||
|
|
@ -354,18 +615,18 @@ 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/master/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}`)
|
||||
|
||||
this._broadcast(
|
||||
DockerService.KIWIX_SERVICE_NAME,
|
||||
SERVICE_NAMES.KIWIX,
|
||||
'preinstall',
|
||||
`Running pre-install actions for Kiwix Serve...`
|
||||
)
|
||||
this._broadcast(
|
||||
DockerService.KIWIX_SERVICE_NAME,
|
||||
SERVICE_NAMES.KIWIX,
|
||||
'preinstall',
|
||||
`Downloading Wikipedia ZIM file from ${WIKIPEDIA_ZIM_URL}. This may take some time...`
|
||||
)
|
||||
|
|
@ -383,13 +644,13 @@ export class DockerService {
|
|||
})
|
||||
|
||||
this._broadcast(
|
||||
DockerService.KIWIX_SERVICE_NAME,
|
||||
SERVICE_NAMES.KIWIX,
|
||||
'preinstall',
|
||||
`Downloaded Wikipedia ZIM file to ${filepath}`
|
||||
)
|
||||
} catch (error) {
|
||||
this._broadcast(
|
||||
DockerService.KIWIX_SERVICE_NAME,
|
||||
SERVICE_NAMES.KIWIX,
|
||||
'preinstall-error',
|
||||
`Failed to download Wikipedia ZIM file: ${error.message}`
|
||||
)
|
||||
|
|
@ -405,6 +666,10 @@ export class DockerService {
|
|||
await service.save()
|
||||
}
|
||||
this.activeInstallations.delete(serviceName)
|
||||
|
||||
// Ensure any partially created container is removed
|
||||
await this._removeServiceContainer(serviceName)
|
||||
|
||||
logger.info(`[DockerService] Cleaned up failed installation for ${serviceName}`)
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
|
|
@ -413,8 +678,365 @@ export class DockerService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect GPU type and toolkit availability.
|
||||
* Primary: Check Docker runtimes via docker.info() (works from inside containers).
|
||||
* Fallback: lspci for host-based installs and AMD detection.
|
||||
*/
|
||||
private async _detectGPUType(): Promise<{ type: 'nvidia' | 'amd' | 'none'; toolkitMissing?: boolean }> {
|
||||
try {
|
||||
// Primary: Check Docker daemon for nvidia runtime (works from inside containers)
|
||||
try {
|
||||
const dockerInfo = await this.docker.info()
|
||||
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) {
|
||||
logger.warn(`[DockerService] Could not query Docker info for GPU runtimes: ${error.message}`)
|
||||
}
|
||||
|
||||
// Fallback: lspci for host-based installs (not available inside Docker)
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
// Check for NVIDIA GPU via lspci
|
||||
try {
|
||||
const { stdout: nvidiaCheck } = await execAsync(
|
||||
'lspci 2>/dev/null | grep -i nvidia || true'
|
||||
)
|
||||
if (nvidiaCheck.trim()) {
|
||||
// GPU hardware found but no nvidia runtime — toolkit not installed
|
||||
logger.warn('[DockerService] NVIDIA GPU detected via lspci but NVIDIA Container Toolkit is not installed')
|
||||
return { type: 'none', toolkitMissing: true }
|
||||
}
|
||||
} catch (error) {
|
||||
// lspci not available (likely inside Docker container), continue
|
||||
}
|
||||
|
||||
// Check for AMD GPU via lspci — restrict to display controller classes to avoid
|
||||
// false positives from AMD CPU host bridges, PCI bridges, and chipset devices.
|
||||
try {
|
||||
const { stdout: amdCheck } = await execAsync(
|
||||
'lspci 2>/dev/null | grep -iE "VGA|3D controller|Display" | grep -iE "amd|radeon" || true'
|
||||
)
|
||||
if (amdCheck.trim()) {
|
||||
logger.info('[DockerService] AMD GPU detected via lspci')
|
||||
await this._persistGPUType('amd')
|
||||
return { type: 'amd' }
|
||||
}
|
||||
} catch (error) {
|
||||
// lspci not available, continue
|
||||
}
|
||||
|
||||
// Last resort: check if we previously detected a GPU and it's likely still present.
|
||||
// This handles cases where live detection fails transiently (e.g., Docker daemon
|
||||
// hiccup, runtime temporarily unavailable) but the hardware hasn't changed.
|
||||
try {
|
||||
const savedType = await KVStore.getValue('gpu.type')
|
||||
if (savedType === 'nvidia' || savedType === 'amd') {
|
||||
logger.info(`[DockerService] No GPU detected live, but KV store has '${savedType}' from previous detection. Using saved value.`)
|
||||
return { type: savedType as 'nvidia' | 'amd' }
|
||||
}
|
||||
} catch {
|
||||
// KV store not available, continue
|
||||
}
|
||||
|
||||
logger.info('[DockerService] No GPU detected')
|
||||
return { type: 'none' }
|
||||
} catch (error) {
|
||||
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) {
|
||||
logger.warn(`[DockerService] Failed to persist GPU type: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover AMD GPU DRI devices dynamically.
|
||||
* Returns an array of device configurations for Docker.
|
||||
*/
|
||||
// private async _discoverAMDDevices(): Promise<
|
||||
// Array<{ PathOnHost: string; PathInContainer: string; CgroupPermissions: string }>
|
||||
// > {
|
||||
// try {
|
||||
// const devices: Array<{
|
||||
// PathOnHost: string
|
||||
// PathInContainer: string
|
||||
// CgroupPermissions: string
|
||||
// }> = []
|
||||
|
||||
// // Always add /dev/kfd (Kernel Fusion Driver)
|
||||
// devices.push({
|
||||
// PathOnHost: '/dev/kfd',
|
||||
// PathInContainer: '/dev/kfd',
|
||||
// CgroupPermissions: 'rwm',
|
||||
// })
|
||||
|
||||
// // Discover DRI devices in /dev/dri/
|
||||
// try {
|
||||
// const driDevices = await readdir('/dev/dri')
|
||||
// for (const device of driDevices) {
|
||||
// const devicePath = `/dev/dri/${device}`
|
||||
// devices.push({
|
||||
// PathOnHost: devicePath,
|
||||
// PathInContainer: devicePath,
|
||||
// CgroupPermissions: 'rwm',
|
||||
// })
|
||||
// }
|
||||
// logger.info(
|
||||
// `[DockerService] Discovered ${driDevices.length} DRI devices: ${driDevices.join(', ')}`
|
||||
// )
|
||||
// } catch (error) {
|
||||
// logger.warn(`[DockerService] Could not read /dev/dri directory: ${error.message}`)
|
||||
// // Fallback to common device names if directory read fails
|
||||
// const fallbackDevices = ['card0', 'renderD128']
|
||||
// for (const device of fallbackDevices) {
|
||||
// devices.push({
|
||||
// PathOnHost: `/dev/dri/${device}`,
|
||||
// PathInContainer: `/dev/dri/${device}`,
|
||||
// CgroupPermissions: 'rwm',
|
||||
// })
|
||||
// }
|
||||
// logger.info(`[DockerService] Using fallback DRI devices: ${fallbackDevices.join(', ')}`)
|
||||
// }
|
||||
|
||||
// return devices
|
||||
// } catch (error) {
|
||||
// logger.error(`[DockerService] Error discovering AMD devices: ${error.message}`)
|
||||
// return []
|
||||
// }
|
||||
// }
|
||||
|
||||
/**
|
||||
* Update a service container to a new image version while preserving volumes and data.
|
||||
* Includes automatic rollback if the new container fails health checks.
|
||||
*/
|
||||
async updateContainer(
|
||||
serviceName: string,
|
||||
targetVersion: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const service = await Service.query().where('service_name', serviceName).first()
|
||||
if (!service) {
|
||||
return { success: false, message: `Service ${serviceName} not found` }
|
||||
}
|
||||
if (!service.installed) {
|
||||
return { success: false, message: `Service ${serviceName} is not installed` }
|
||||
}
|
||||
if (this.activeInstallations.has(serviceName)) {
|
||||
return { success: false, message: `Service ${serviceName} already has an operation in progress` }
|
||||
}
|
||||
|
||||
this.activeInstallations.add(serviceName)
|
||||
|
||||
// Compute new image string
|
||||
const currentImage = service.container_image
|
||||
const imageBase = currentImage.includes(':')
|
||||
? currentImage.substring(0, currentImage.lastIndexOf(':'))
|
||||
: currentImage
|
||||
const newImage = `${imageBase}:${targetVersion}`
|
||||
|
||||
// Step 1: Pull new image
|
||||
this._broadcast(serviceName, 'update-pulling', `Pulling image ${newImage}...`)
|
||||
const pullStream = await this.docker.pull(newImage)
|
||||
await new Promise((res) => this.docker.modem.followProgress(pullStream, res))
|
||||
|
||||
// Step 2: Find and stop existing container
|
||||
this._broadcast(serviceName, 'update-stopping', `Stopping current container...`)
|
||||
const containers = await this.docker.listContainers({ all: true })
|
||||
const existingContainer = containers.find((c) => c.Names.includes(`/${serviceName}`))
|
||||
|
||||
if (!existingContainer) {
|
||||
this.activeInstallations.delete(serviceName)
|
||||
return { success: false, message: `Container for ${serviceName} not found` }
|
||||
}
|
||||
|
||||
const oldContainer = this.docker.getContainer(existingContainer.Id)
|
||||
|
||||
// Inspect to capture full config before stopping
|
||||
const inspectData = await oldContainer.inspect()
|
||||
|
||||
if (existingContainer.State === 'running') {
|
||||
await oldContainer.stop({ t: 15 })
|
||||
}
|
||||
|
||||
// Step 3: Rename old container as safety net
|
||||
const oldName = `${serviceName}_old`
|
||||
await oldContainer.rename({ name: oldName })
|
||||
|
||||
// Step 4: Create new container with inspected config + new image
|
||||
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,
|
||||
Env: inspectData.Config?.Env || undefined,
|
||||
Cmd: inspectData.Config?.Cmd || undefined,
|
||||
ExposedPorts: inspectData.Config?.ExposedPorts || undefined,
|
||||
WorkingDir: inspectData.Config?.WorkingDir || undefined,
|
||||
User: inspectData.Config?.User || undefined,
|
||||
HostConfig: {
|
||||
Binds: hostConfig.Binds || undefined,
|
||||
PortBindings: hostConfig.PortBindings || undefined,
|
||||
RestartPolicy: hostConfig.RestartPolicy || undefined,
|
||||
DeviceRequests: serviceName === SERVICE_NAMES.OLLAMA ? updatedDeviceRequests : (hostConfig.DeviceRequests || undefined),
|
||||
Devices: hostConfig.Devices || undefined,
|
||||
},
|
||||
NetworkingConfig: inspectData.NetworkSettings?.Networks
|
||||
? {
|
||||
EndpointsConfig: Object.fromEntries(
|
||||
Object.keys(inspectData.NetworkSettings.Networks).map((net) => [net, {}])
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
|
||||
// Remove undefined values from HostConfig
|
||||
Object.keys(newContainerConfig.HostConfig).forEach((key) => {
|
||||
if (newContainerConfig.HostConfig[key] === undefined) {
|
||||
delete newContainerConfig.HostConfig[key]
|
||||
}
|
||||
})
|
||||
|
||||
let newContainer: any
|
||||
try {
|
||||
newContainer = await this.docker.createContainer(newContainerConfig)
|
||||
} catch (createError) {
|
||||
// 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)
|
||||
await rollbackContainer.rename({ name: serviceName })
|
||||
await rollbackContainer.start()
|
||||
this.activeInstallations.delete(serviceName)
|
||||
return { success: false, message: `Failed to create updated container: ${createError.message}` }
|
||||
}
|
||||
|
||||
// Step 5: Start new container
|
||||
this._broadcast(serviceName, 'update-starting', `Starting updated container...`)
|
||||
await newContainer.start()
|
||||
|
||||
// Step 6: Health check — verify container stays running for 5 seconds
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000))
|
||||
const newContainerInfo = await newContainer.inspect()
|
||||
|
||||
if (newContainerInfo.State?.Running) {
|
||||
// Healthy — clean up old container
|
||||
try {
|
||||
const oldContainerRef = this.docker.getContainer(
|
||||
(await this.docker.listContainers({ all: true })).find((c) =>
|
||||
c.Names.includes(`/${oldName}`)
|
||||
)?.Id || ''
|
||||
)
|
||||
await oldContainerRef.remove({ force: true })
|
||||
} catch {
|
||||
// Old container may already be gone
|
||||
}
|
||||
|
||||
// Update DB
|
||||
service.container_image = newImage
|
||||
service.available_update_version = null
|
||||
await service.save()
|
||||
|
||||
this.activeInstallations.delete(serviceName)
|
||||
this._broadcast(
|
||||
serviceName,
|
||||
'update-complete',
|
||||
`Successfully updated ${serviceName} to ${targetVersion}`
|
||||
)
|
||||
return { success: true, message: `Service ${serviceName} updated to ${targetVersion}` }
|
||||
} else {
|
||||
// Unhealthy — rollback
|
||||
this._broadcast(
|
||||
serviceName,
|
||||
'update-rollback',
|
||||
`New container failed health check. Rolling back to previous version...`
|
||||
)
|
||||
|
||||
try {
|
||||
await newContainer.stop({ t: 5 }).catch(() => {})
|
||||
await newContainer.remove({ force: true })
|
||||
} catch {
|
||||
// Best effort cleanup
|
||||
}
|
||||
|
||||
// Restore old container
|
||||
const oldContainers = await this.docker.listContainers({ all: true })
|
||||
const oldRef = oldContainers.find((c) => c.Names.includes(`/${oldName}`))
|
||||
if (oldRef) {
|
||||
const rollbackContainer = this.docker.getContainer(oldRef.Id)
|
||||
await rollbackContainer.rename({ name: serviceName })
|
||||
await rollbackContainer.start()
|
||||
}
|
||||
|
||||
this.activeInstallations.delete(serviceName)
|
||||
return {
|
||||
success: false,
|
||||
message: `Update failed: new container did not stay running. Rolled back to previous version.`,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.activeInstallations.delete(serviceName)
|
||||
this._broadcast(
|
||||
serviceName,
|
||||
'update-rollback',
|
||||
`Update failed: ${error.message}`
|
||||
)
|
||||
logger.error(`[DockerService] Update failed for ${serviceName}: ${error.message}`)
|
||||
return { success: false, message: `Update failed: ${error.message}` }
|
||||
}
|
||||
}
|
||||
|
||||
private _broadcast(service: string, status: string, message: string) {
|
||||
transmit.broadcast('service-installation', {
|
||||
transmit.broadcast(BROADCAST_CHANNELS.SERVICE_INSTALLATION, {
|
||||
service_name: service,
|
||||
timestamp: new Date().toISOString(),
|
||||
status,
|
||||
|
|
@ -441,4 +1063,22 @@ export class DockerService {
|
|||
throw new Error(`Invalid container configuration: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a Docker image exists locally.
|
||||
* @param imageName - The name and tag of the image (e.g., "nginx:latest")
|
||||
* @returns - True if the image exists locally, false otherwise
|
||||
*/
|
||||
private async _checkImageExists(imageName: string): Promise<boolean> {
|
||||
try {
|
||||
const images = await this.docker.listImages()
|
||||
|
||||
// Check if any image has a RepoTag that matches the requested image
|
||||
return images.some((image) => image.RepoTags && image.RepoTags.includes(imageName))
|
||||
} catch (error) {
|
||||
logger.warn(`Error checking if image exists: ${error.message}`)
|
||||
// If run into an error, assume the image does not exist
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,20 @@ import { streamToString } from '../../util/docs.js'
|
|||
import { getFile, getFileStatsIfExists, listDirectoryContentsRecursive } from '../utils/fs.js'
|
||||
import path from 'path'
|
||||
import InternalServerErrorException from '#exceptions/internal_server_error_exception'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
|
||||
export class DocsService {
|
||||
private docsPath = path.join(process.cwd(), 'docs')
|
||||
|
||||
private static readonly DOC_ORDER: Record<string, number> = {
|
||||
'home': 1,
|
||||
'getting-started': 2,
|
||||
'use-cases': 3,
|
||||
'faq': 4,
|
||||
'about': 5,
|
||||
'release-notes': 6,
|
||||
}
|
||||
|
||||
async getDocs() {
|
||||
const contents = await listDirectoryContentsRecursive(this.docsPath)
|
||||
const files: Array<{ title: string; slug: string }> = []
|
||||
|
|
@ -20,7 +31,11 @@ export class DocsService {
|
|||
}
|
||||
}
|
||||
|
||||
return files.sort((a, b) => a.title.localeCompare(b.title))
|
||||
return files.sort((a, b) => {
|
||||
const orderA = DocsService.DOC_ORDER[a.slug] ?? 999
|
||||
const orderB = DocsService.DOC_ORDER[b.slug] ?? 999
|
||||
return orderA - orderB
|
||||
})
|
||||
}
|
||||
|
||||
parse(content: string) {
|
||||
|
|
@ -32,13 +47,13 @@ export class DocsService {
|
|||
// Filter out attribute-undefined errors which may be caused by emojis and special characters
|
||||
const criticalErrors = errors.filter((e) => e.error.id !== 'attribute-undefined')
|
||||
if (criticalErrors.length > 0) {
|
||||
console.error('Markdoc validation errors:', errors.map((e) => JSON.stringify(e.error)).join(', '))
|
||||
logger.error('Markdoc validation errors:', errors.map((e) => JSON.stringify(e.error)).join(', '))
|
||||
throw new Error('Markdoc validation failed')
|
||||
}
|
||||
|
||||
return Markdoc.transform(ast, config)
|
||||
} catch (error) {
|
||||
console.log('Error parsing Markdoc content:', error)
|
||||
logger.error('Error parsing Markdoc content:', error)
|
||||
throw new InternalServerErrorException(`Error parsing content: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
|
@ -51,12 +66,19 @@ export class DocsService {
|
|||
|
||||
const filename = _filename.endsWith('.md') ? _filename : `${_filename}.md`
|
||||
|
||||
const fileExists = await getFileStatsIfExists(path.join(this.docsPath, filename))
|
||||
// Prevent path traversal — resolved path must stay within the docs directory
|
||||
const basePath = path.resolve(this.docsPath)
|
||||
const fullPath = path.resolve(path.join(this.docsPath, filename))
|
||||
if (!fullPath.startsWith(basePath + path.sep)) {
|
||||
throw new Error('Invalid document slug')
|
||||
}
|
||||
|
||||
const fileExists = await getFileStatsIfExists(fullPath)
|
||||
if (!fileExists) {
|
||||
throw new Error(`File not found: ${filename}`)
|
||||
}
|
||||
|
||||
const fileStream = await getFile(path.join(this.docsPath, filename), 'stream')
|
||||
const fileStream = await getFile(fullPath, 'stream')
|
||||
if (!fileStream) {
|
||||
throw new Error(`Failed to read file stream: ${filename}`)
|
||||
}
|
||||
|
|
@ -67,9 +89,17 @@ export class DocsService {
|
|||
}
|
||||
}
|
||||
|
||||
private static readonly TITLE_OVERRIDES: Record<string, string> = {
|
||||
'faq': 'FAQ',
|
||||
}
|
||||
|
||||
private prettify(filename: string) {
|
||||
const slug = filename.replace(/\.md$/, '')
|
||||
if (DocsService.TITLE_OVERRIDES[slug]) {
|
||||
return DocsService.TITLE_OVERRIDES[slug]
|
||||
}
|
||||
// Remove hyphens, underscores, and file extension
|
||||
const cleaned = filename.replace(/_/g, ' ').replace(/\.md$/, '').replace(/-/g, ' ')
|
||||
const cleaned = slug.replace(/_/g, ' ').replace(/-/g, ' ')
|
||||
// Convert to Title Case
|
||||
const titleCased = cleaned.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||
return titleCased.charAt(0).toUpperCase() + titleCased.slice(1)
|
||||
|
|
@ -115,6 +145,58 @@ export class DocsService {
|
|||
class: { type: String }
|
||||
}
|
||||
},
|
||||
table: {
|
||||
render: 'Table',
|
||||
},
|
||||
thead: {
|
||||
render: 'TableHead',
|
||||
},
|
||||
tbody: {
|
||||
render: 'TableBody',
|
||||
},
|
||||
tr: {
|
||||
render: 'TableRow',
|
||||
},
|
||||
th: {
|
||||
render: 'TableHeader',
|
||||
},
|
||||
td: {
|
||||
render: 'TableCell',
|
||||
},
|
||||
paragraph: {
|
||||
render: 'Paragraph',
|
||||
},
|
||||
image: {
|
||||
render: 'Image',
|
||||
attributes: {
|
||||
src: { type: String, required: true },
|
||||
alt: { type: String },
|
||||
title: { type: String },
|
||||
},
|
||||
},
|
||||
link: {
|
||||
render: 'Link',
|
||||
attributes: {
|
||||
href: { type: String, required: true },
|
||||
title: { type: String },
|
||||
},
|
||||
},
|
||||
fence: {
|
||||
render: 'CodeBlock',
|
||||
attributes: {
|
||||
content: { type: String },
|
||||
language: { type: String },
|
||||
},
|
||||
},
|
||||
code: {
|
||||
render: 'InlineCode',
|
||||
attributes: {
|
||||
content: { type: String },
|
||||
},
|
||||
},
|
||||
hr: {
|
||||
render: 'HorizontalRule',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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 { normalize } from 'path'
|
||||
|
||||
|
|
@ -9,17 +10,55 @@ 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 jobs = await queue.getJobs(['waiting', 'active', 'delayed'])
|
||||
const fileJobs = await queue.getJobs(['waiting', 'active', 'delayed', 'failed'])
|
||||
|
||||
return jobs
|
||||
.map((job) => ({
|
||||
jobId: job.id!.toString(),
|
||||
url: job.data.url,
|
||||
progress: parseInt(job.progress.toString(), 10),
|
||||
filepath: normalize(job.data.filepath),
|
||||
filetype: job.data.filetype,
|
||||
}))
|
||||
.filter((job) => !filetype || job.filetype === filetype)
|
||||
const fileDownloads = fileJobs.map((job) => ({
|
||||
jobId: job.id!.toString(),
|
||||
url: job.data.url,
|
||||
progress: parseInt(job.progress.toString(), 10),
|
||||
filepath: normalize(job.data.filepath),
|
||||
filetype: job.data.filetype,
|
||||
status: (job.failedReason ? 'failed' : 'active') as 'active' | 'failed',
|
||||
failedReason: job.failedReason || undefined,
|
||||
}))
|
||||
|
||||
// Get Ollama model download jobs
|
||||
const modelQueue = this.queueService.getQueue(DownloadModelJob.queue)
|
||||
const modelJobs = await modelQueue.getJobs(['waiting', 'active', 'delayed', 'failed'])
|
||||
|
||||
const modelDownloads = modelJobs.map((job) => ({
|
||||
jobId: job.id!.toString(),
|
||||
url: job.data.modelName || 'Unknown Model', // Use model name as url
|
||||
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]
|
||||
|
||||
// Filter by filetype if specified
|
||||
const filtered = allDownloads.filter((job) => !filetype || job.filetype === filetype)
|
||||
|
||||
// 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) {
|
||||
await job.remove()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { BaseStylesFile, MapLayer } from '../../types/maps.js'
|
||||
import {
|
||||
DownloadCollectionOperation,
|
||||
DownloadRemoteSuccessCallback,
|
||||
FileEntry,
|
||||
} from '../../types/files.js'
|
||||
|
|
@ -9,22 +8,18 @@ import { extract } from 'tar'
|
|||
import env from '#start/env'
|
||||
import {
|
||||
listDirectoryContentsRecursive,
|
||||
listDirectoryContents,
|
||||
getFileStatsIfExists,
|
||||
deleteFileIfExists,
|
||||
getFile,
|
||||
ensureDirectoryExists,
|
||||
} from '../utils/fs.js'
|
||||
import { join } from 'path'
|
||||
import { join, resolve, sep } from 'path'
|
||||
import urlJoin from 'url-join'
|
||||
import axios from 'axios'
|
||||
import { RunDownloadJob } from '#jobs/run_download_job'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import { CuratedCollectionsFile, CuratedCollectionWithStatus } from '../../types/downloads.js'
|
||||
import CuratedCollection from '#models/curated_collection'
|
||||
import vine from '@vinejs/vine'
|
||||
import { curatedCollectionsFileSchema } from '#validators/curated_collections'
|
||||
import CuratedCollectionResource from '#models/curated_collection_resource'
|
||||
import InstalledResource from '#models/installed_resource'
|
||||
import { CollectionManifestService } from './collection_manifest_service.js'
|
||||
import type { CollectionWithStatus, MapsSpec } from '../../types/collections.js'
|
||||
|
||||
const BASE_ASSETS_MIME_TYPES = [
|
||||
'application/gzip',
|
||||
|
|
@ -32,15 +27,11 @@ const BASE_ASSETS_MIME_TYPES = [
|
|||
'application/octet-stream',
|
||||
]
|
||||
|
||||
const COLLECTIONS_URL =
|
||||
'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/master/collections/maps.json'
|
||||
|
||||
const PMTILES_ATTRIBUTION =
|
||||
'<a href="https://github.com/protomaps/basemaps">Protomaps</a> © <a href="https://openstreetmap.org">OpenStreetMap</a>'
|
||||
const PMTILES_MIME_TYPES = ['application/vnd.pmtiles', 'application/octet-stream']
|
||||
|
||||
interface IMapService {
|
||||
downloadCollection: DownloadCollectionOperation
|
||||
downloadRemoteSuccessCallback: DownloadRemoteSuccessCallback
|
||||
}
|
||||
|
||||
|
|
@ -50,6 +41,7 @@ export class MapService implements IMapService {
|
|||
private readonly basemapsAssetsDir = 'basemaps-assets'
|
||||
private readonly baseAssetsTarFile = 'base-assets.tar.gz'
|
||||
private readonly baseDirPath = join(process.cwd(), this.mapStoragePath)
|
||||
private baseAssetsExistCache: boolean | null = null
|
||||
|
||||
async listRegions() {
|
||||
const files = (await this.listAllMapStorageItems()).filter(
|
||||
|
|
@ -93,37 +85,39 @@ export class MapService implements IMapService {
|
|||
|
||||
await deleteFileIfExists(tempTarPath)
|
||||
|
||||
// Invalidate cache since we just downloaded new assets
|
||||
this.baseAssetsExistCache = true
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async downloadCollection(slug: string) {
|
||||
const collection = await CuratedCollection.query()
|
||||
.where('slug', slug)
|
||||
.andWhere('type', 'map')
|
||||
.first()
|
||||
if (!collection) {
|
||||
return null
|
||||
}
|
||||
async downloadCollection(slug: string): Promise<string[] | null> {
|
||||
const manifestService = new CollectionManifestService()
|
||||
const spec = await manifestService.getSpecWithFallback<MapsSpec>('maps')
|
||||
if (!spec) return null
|
||||
|
||||
const resources = await collection.related('resources').query().where('downloaded', false)
|
||||
if (resources.length === 0) {
|
||||
return null
|
||||
}
|
||||
const collection = spec.collections.find((c) => c.slug === slug)
|
||||
if (!collection) return null
|
||||
|
||||
// Filter out already installed
|
||||
const installed = await InstalledResource.query().where('resource_type', 'map')
|
||||
const installedIds = new Set(installed.map((r) => r.resource_id))
|
||||
const toDownload = collection.resources.filter((r) => !installedIds.has(r.id))
|
||||
|
||||
if (toDownload.length === 0) return null
|
||||
|
||||
const downloadUrls = resources.map((res) => res.url)
|
||||
const downloadFilenames: string[] = []
|
||||
|
||||
for (const url of downloadUrls) {
|
||||
const existing = await RunDownloadJob.getByUrl(url)
|
||||
for (const resource of toDownload) {
|
||||
const existing = await RunDownloadJob.getByUrl(resource.url)
|
||||
if (existing) {
|
||||
logger.warn(`[MapService] Download already in progress for URL ${url}, skipping.`)
|
||||
logger.warn(`[MapService] Download already in progress for URL ${resource.url}, skipping.`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract the filename from the URL
|
||||
const filename = url.split('/').pop()
|
||||
const filename = resource.url.split('/').pop()
|
||||
if (!filename) {
|
||||
logger.warn(`[MapService] Could not determine filename from URL ${url}, skipping.`)
|
||||
logger.warn(`[MapService] Could not determine filename from URL ${resource.url}, skipping.`)
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -131,12 +125,17 @@ export class MapService implements IMapService {
|
|||
const filepath = join(process.cwd(), this.mapStoragePath, 'pmtiles', filename)
|
||||
|
||||
await RunDownloadJob.dispatch({
|
||||
url,
|
||||
url: resource.url,
|
||||
filepath,
|
||||
timeout: 30000,
|
||||
allowedMimeTypes: PMTILES_MIME_TYPES,
|
||||
forceNew: true,
|
||||
filetype: 'map',
|
||||
resourceMetadata: {
|
||||
resource_id: resource.id,
|
||||
version: resource.version,
|
||||
collection_ref: slug,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -144,11 +143,33 @@ export class MapService implements IMapService {
|
|||
}
|
||||
|
||||
async downloadRemoteSuccessCallback(urls: string[], _: boolean) {
|
||||
const resources = await CuratedCollectionResource.query().whereIn('url', urls)
|
||||
for (const resource of resources) {
|
||||
resource.downloaded = true
|
||||
await resource.save()
|
||||
logger.info(`[MapService] Marked resource as downloaded: ${resource.url}`)
|
||||
// Create InstalledResource entries for downloaded map files
|
||||
for (const url of urls) {
|
||||
const filename = url.split('/').pop()
|
||||
if (!filename) continue
|
||||
|
||||
const parsed = CollectionManifestService.parseMapFilename(filename)
|
||||
if (!parsed) continue
|
||||
|
||||
const filepath = join(process.cwd(), this.mapStoragePath, 'pmtiles', filename)
|
||||
const stats = await getFileStatsIfExists(filepath)
|
||||
|
||||
try {
|
||||
const { DateTime } = await import('luxon')
|
||||
await InstalledResource.updateOrCreate(
|
||||
{ resource_id: parsed.resource_id, resource_type: 'map' },
|
||||
{
|
||||
version: parsed.version,
|
||||
url: url,
|
||||
file_path: filepath,
|
||||
file_size_bytes: stats ? Number(stats.size) : null,
|
||||
installed_at: DateTime.now(),
|
||||
}
|
||||
)
|
||||
logger.info(`[MapService] Created InstalledResource entry for: ${parsed.resource_id}`)
|
||||
} catch (error) {
|
||||
logger.error(`[MapService] Failed to create InstalledResource for ${filename}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -170,6 +191,21 @@ export class MapService implements IMapService {
|
|||
|
||||
const filepath = join(process.cwd(), this.mapStoragePath, 'pmtiles', filename)
|
||||
|
||||
|
||||
// First, ensure base assets are present - regions depend 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.'
|
||||
)
|
||||
}
|
||||
|
||||
// Parse resource metadata
|
||||
const parsedFilename = CollectionManifestService.parseMapFilename(filename)
|
||||
const resourceMetadata = parsedFilename
|
||||
? { resource_id: parsedFilename.resource_id, version: parsedFilename.version, collection_ref: null }
|
||||
: undefined
|
||||
|
||||
// Dispatch background job
|
||||
const result = await RunDownloadJob.dispatch({
|
||||
url,
|
||||
|
|
@ -178,6 +214,7 @@ export class MapService implements IMapService {
|
|||
allowedMimeTypes: PMTILES_MIME_TYPES,
|
||||
forceNew: true,
|
||||
filetype: 'map',
|
||||
resourceMetadata,
|
||||
})
|
||||
|
||||
if (!result.job) {
|
||||
|
|
@ -207,6 +244,7 @@ export class MapService implements IMapService {
|
|||
}
|
||||
|
||||
// Perform a HEAD request to get the content length
|
||||
const { default: axios } = await import('axios')
|
||||
const response = await axios.head(url)
|
||||
|
||||
if (response.status !== 200) {
|
||||
|
|
@ -217,12 +255,12 @@ export class MapService implements IMapService {
|
|||
const size = contentLength ? parseInt(contentLength, 10) : 0
|
||||
|
||||
return { filename, size }
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
return { message: `Preflight check failed: ${error.message}` }
|
||||
}
|
||||
}
|
||||
|
||||
async generateStylesJSON() {
|
||||
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')
|
||||
}
|
||||
|
|
@ -236,12 +274,15 @@ export class MapService implements IMapService {
|
|||
const rawStyles = JSON.parse(baseStyle.toString()) as BaseStylesFile
|
||||
|
||||
const regions = (await this.listRegions()).files
|
||||
const sources = this.generateSourcesArray(regions)
|
||||
|
||||
const localUrl = env.get('URL')
|
||||
const withProtocol = localUrl.startsWith('http') ? localUrl : `http://${localUrl}`
|
||||
const baseUrlPath = urlJoin(this.mapStoragePath, this.basemapsAssetsDir)
|
||||
const baseUrl = new URL(baseUrlPath, withProtocol).toString()
|
||||
/** If we have the host, use it to build public URLs, otherwise we'll fallback to defaults
|
||||
* This is mainly useful because we need to know what host the user is accessing from in order to
|
||||
* properly generate URLs in the styles file
|
||||
* 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, protocol)
|
||||
const baseUrl = this.getPublicFileBaseUrl(host, this.basemapsAssetsDir, protocol)
|
||||
|
||||
const styles = await this.generateStylesFile(
|
||||
rawStyles,
|
||||
|
|
@ -253,62 +294,47 @@ export class MapService implements IMapService {
|
|||
return styles
|
||||
}
|
||||
|
||||
async checkBaseAssetsExist() {
|
||||
const storageContents = await this.listMapStorageItems()
|
||||
const baseStyleItem = storageContents.find(
|
||||
(item) => item.type === 'file' && item.name === this.baseStylesFile
|
||||
)
|
||||
const basemapsAssetsItem = storageContents.find(
|
||||
(item) => item.type === 'directory' && item.name === this.basemapsAssetsDir
|
||||
)
|
||||
|
||||
return !!baseStyleItem && !!basemapsAssetsItem
|
||||
}
|
||||
|
||||
async listCuratedCollections(): Promise<CuratedCollectionWithStatus[]> {
|
||||
const collections = await CuratedCollection.query().where('type', 'map').preload('resources')
|
||||
return collections.map((collection) => ({
|
||||
...(collection.serialize() as CuratedCollection),
|
||||
all_downloaded: collection.resources.every((res) => res.downloaded),
|
||||
}))
|
||||
async listCuratedCollections(): Promise<CollectionWithStatus[]> {
|
||||
const manifestService = new CollectionManifestService()
|
||||
return manifestService.getMapCollectionsWithStatus()
|
||||
}
|
||||
|
||||
async fetchLatestCollections(): Promise<boolean> {
|
||||
try {
|
||||
const response = await axios.get<CuratedCollectionsFile>(COLLECTIONS_URL)
|
||||
|
||||
const validated = await vine.validate({
|
||||
schema: curatedCollectionsFileSchema,
|
||||
data: response.data,
|
||||
})
|
||||
|
||||
for (const collection of validated.collections) {
|
||||
const collectionResult = await CuratedCollection.updateOrCreate(
|
||||
{ slug: collection.slug },
|
||||
{
|
||||
...collection,
|
||||
type: 'map',
|
||||
}
|
||||
)
|
||||
logger.info(`[MapService] Upserted curated collection: ${collection.slug}`)
|
||||
|
||||
await collectionResult.related('resources').createMany(collection.resources)
|
||||
logger.info(
|
||||
`[MapService] Upserted ${collection.resources.length} resources for collection: ${collection.slug}`
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
logger.error(`[MapService] Failed to download latest Kiwix collections:`, error)
|
||||
return false
|
||||
}
|
||||
const manifestService = new CollectionManifestService()
|
||||
return manifestService.fetchAndCacheSpec('maps')
|
||||
}
|
||||
|
||||
private async listMapStorageItems(): Promise<FileEntry[]> {
|
||||
async ensureBaseAssets(): Promise<boolean> {
|
||||
const exists = await this.checkBaseAssetsExist()
|
||||
if (exists) {
|
||||
return true
|
||||
}
|
||||
|
||||
return await this.downloadBaseAssets()
|
||||
}
|
||||
|
||||
private async checkBaseAssetsExist(useCache: boolean = true): Promise<boolean> {
|
||||
// Return cached result if available and caching is enabled
|
||||
if (useCache && this.baseAssetsExistCache !== null) {
|
||||
return this.baseAssetsExistCache
|
||||
}
|
||||
|
||||
await ensureDirectoryExists(this.baseDirPath)
|
||||
return await listDirectoryContents(this.baseDirPath)
|
||||
|
||||
const baseStylePath = join(this.baseDirPath, this.baseStylesFile)
|
||||
const basemapsAssetsPath = join(this.baseDirPath, this.basemapsAssetsDir)
|
||||
|
||||
const [baseStyleExists, basemapsAssetsExists] = await Promise.all([
|
||||
getFileStatsIfExists(baseStylePath),
|
||||
getFileStatsIfExists(basemapsAssetsPath),
|
||||
])
|
||||
|
||||
const exists = !!baseStyleExists && !!basemapsAssetsExists
|
||||
|
||||
// update cache
|
||||
this.baseAssetsExistCache = exists
|
||||
|
||||
return exists
|
||||
}
|
||||
|
||||
private async listAllMapStorageItems(): Promise<FileEntry[]> {
|
||||
|
|
@ -316,18 +342,17 @@ export class MapService implements IMapService {
|
|||
return await listDirectoryContentsRecursive(this.baseDirPath)
|
||||
}
|
||||
|
||||
private generateSourcesArray(regions: FileEntry[]): BaseStylesFile['sources'][] {
|
||||
const localUrl = env.get('URL')
|
||||
private generateSourcesArray(host: string | null, regions: FileEntry[], protocol: string = 'http'): BaseStylesFile['sources'][] {
|
||||
const sources: BaseStylesFile['sources'][] = []
|
||||
const baseUrl = this.getPublicFileBaseUrl(host, 'pmtiles', protocol)
|
||||
|
||||
for (const region of regions) {
|
||||
if (region.type === 'file' && region.name.endsWith('.pmtiles')) {
|
||||
const regionName = region.name.replace('.pmtiles', '')
|
||||
// Strip .pmtiles and date suffix (e.g. "alaska_2025-12" -> "alaska") for stable source names
|
||||
const parsed = CollectionManifestService.parseMapFilename(region.name)
|
||||
const regionName = parsed ? parsed.resource_id : region.name.replace('.pmtiles', '')
|
||||
const source: BaseStylesFile['sources'] = {}
|
||||
const sourceUrl = new URL(
|
||||
urlJoin(this.mapStoragePath, 'pmtiles', region.name),
|
||||
localUrl.startsWith('http') ? localUrl : `http://${localUrl}`
|
||||
).toString()
|
||||
const sourceUrl = urlJoin(baseUrl, region.name)
|
||||
|
||||
source[regionName] = {
|
||||
type: 'vector',
|
||||
|
|
@ -375,11 +400,17 @@ export class MapService implements IMapService {
|
|||
|
||||
async delete(file: string): Promise<void> {
|
||||
let fileName = file
|
||||
if (!fileName.endsWith('.zim')) {
|
||||
fileName += '.zim'
|
||||
if (!fileName.endsWith('.pmtiles')) {
|
||||
fileName += '.pmtiles'
|
||||
}
|
||||
|
||||
const fullPath = join(this.baseDirPath, fileName)
|
||||
const basePath = resolve(join(this.baseDirPath, 'pmtiles'))
|
||||
const fullPath = resolve(join(basePath, fileName))
|
||||
|
||||
// Prevent path traversal — resolved path must stay within the storage directory
|
||||
if (!fullPath.startsWith(basePath + sep)) {
|
||||
throw new Error('Invalid filename')
|
||||
}
|
||||
|
||||
const exists = await getFileStatsIfExists(fullPath)
|
||||
if (!exists) {
|
||||
|
|
@ -387,5 +418,40 @@ export class MapService implements IMapService {
|
|||
}
|
||||
|
||||
await deleteFileIfExists(fullPath)
|
||||
|
||||
// Clean up InstalledResource entry
|
||||
const parsed = CollectionManifestService.parseMapFilename(fileName)
|
||||
if (parsed) {
|
||||
await InstalledResource.query()
|
||||
.where('resource_id', parsed.resource_id)
|
||||
.where('resource_type', 'map')
|
||||
.delete()
|
||||
logger.info(`[MapService] Deleted InstalledResource entry for: ${parsed.resource_id}`)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Gets the appropriate public URL for a map asset depending on environment
|
||||
*/
|
||||
private getPublicFileBaseUrl(specifiedHost: string | null, childPath: string, protocol: string = 'http'): string {
|
||||
function getHost() {
|
||||
try {
|
||||
const localUrlRaw = env.get('URL')
|
||||
if (!localUrlRaw) return 'localhost'
|
||||
|
||||
const localUrl = new URL(localUrlRaw)
|
||||
return localUrl.host
|
||||
} catch (error) {
|
||||
return 'localhost'
|
||||
}
|
||||
}
|
||||
|
||||
const host = specifiedHost || getHost()
|
||||
const withProtocol = host.startsWith('http') ? host : `${protocol}://${host}`
|
||||
const baseUrlPath =
|
||||
process.env.NODE_ENV === 'production' ? childPath : urlJoin(this.mapStoragePath, childPath)
|
||||
|
||||
const baseUrl = new URL(baseUrlPath, withProtocol).toString()
|
||||
return baseUrl
|
||||
}
|
||||
}
|
||||
|
|
|
|||
421
admin/app/services/ollama_service.ts
Normal file
421
admin/app/services/ollama_service.ts
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
import { inject } from '@adonisjs/core'
|
||||
import { ChatRequest, Ollama } from 'ollama'
|
||||
import { NomadOllamaModel } from '../../types/ollama.js'
|
||||
import { FALLBACK_RECOMMENDED_OLLAMA_MODELS } from '../../constants/ollama.js'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import axios from 'axios'
|
||||
import { DownloadModelJob } from '#jobs/download_model_job'
|
||||
import { SERVICE_NAMES } from '../../constants/service_names.js'
|
||||
import transmit from '@adonisjs/transmit/services/main'
|
||||
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'
|
||||
|
||||
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
|
||||
|
||||
@inject()
|
||||
export class OllamaService {
|
||||
private ollama: Ollama | null = null
|
||||
private ollamaInitPromise: Promise<void> | null = null
|
||||
|
||||
constructor() { }
|
||||
|
||||
private async _initializeOllamaClient() {
|
||||
if (!this.ollamaInitPromise) {
|
||||
this.ollamaInitPromise = (async () => {
|
||||
const dockerService = new (await import('./docker_service.js')).DockerService()
|
||||
const qdrantUrl = await dockerService.getServiceURL(SERVICE_NAMES.OLLAMA)
|
||||
if (!qdrantUrl) {
|
||||
throw new Error('Ollama service is not installed or running.')
|
||||
}
|
||||
this.ollama = new Ollama({ host: qdrantUrl })
|
||||
})()
|
||||
}
|
||||
return this.ollamaInitPromise
|
||||
}
|
||||
|
||||
private async _ensureDependencies() {
|
||||
if (!this.ollama) {
|
||||
await this._initializeOllamaClient()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
async downloadModel(model: string, progressCallback?: (percent: number) => void): Promise<{ success: boolean; message: string; retryable?: boolean }> {
|
||||
try {
|
||||
await this._ensureDependencies()
|
||||
if (!this.ollama) {
|
||||
throw new Error('Ollama client is not initialized.')
|
||||
}
|
||||
|
||||
// See if model is already installed
|
||||
const installedModels = await this.getModels()
|
||||
if (installedModels && installedModels.some((m) => m.name === model)) {
|
||||
logger.info(`[OllamaService] Model "${model}" is already installed.`)
|
||||
return { success: true, message: 'Model is already installed.' }
|
||||
}
|
||||
|
||||
// Returns AbortableAsyncIterator<ProgressResponse>
|
||||
const downloadStream = await this.ollama.pull({
|
||||
model,
|
||||
stream: true,
|
||||
})
|
||||
|
||||
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}": ${errorMessage}`
|
||||
)
|
||||
|
||||
// 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 }
|
||||
}
|
||||
}
|
||||
|
||||
async dispatchModelDownload(modelName: string): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
logger.info(`[OllamaService] Dispatching model download for ${modelName} via job queue`)
|
||||
|
||||
await DownloadModelJob.dispatch({
|
||||
modelName,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message:
|
||||
'Model download has been queued successfully. It will start shortly after Ollama and Open WebUI are ready (if not already).',
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[OllamaService] Failed to dispatch model download for ${modelName}: ${error instanceof Error ? error.message : error}`
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to queue model download. Please try again.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async getClient() {
|
||||
await this._ensureDependencies()
|
||||
return this.ollama!
|
||||
}
|
||||
|
||||
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,
|
||||
stream: false,
|
||||
})
|
||||
}
|
||||
|
||||
public async chatStream(chatRequest: ChatRequest) {
|
||||
await this._ensureDependencies()
|
||||
if (!this.ollama) {
|
||||
throw new Error('Ollama client is not initialized.')
|
||||
}
|
||||
return await this.ollama.chat({
|
||||
...chatRequest,
|
||||
stream: true,
|
||||
})
|
||||
}
|
||||
|
||||
public async checkModelHasThinking(modelName: string): Promise<boolean> {
|
||||
await this._ensureDependencies()
|
||||
if (!this.ollama) {
|
||||
throw new Error('Ollama client is not initialized.')
|
||||
}
|
||||
|
||||
const modelInfo = await this.ollama.show({
|
||||
model: modelName,
|
||||
})
|
||||
|
||||
return modelInfo.capabilities.includes('thinking')
|
||||
}
|
||||
|
||||
public async deleteModel(modelName: string) {
|
||||
await this._ensureDependencies()
|
||||
if (!this.ollama) {
|
||||
throw new Error('Ollama client is not initialized.')
|
||||
}
|
||||
|
||||
return await this.ollama.delete({
|
||||
model: modelName,
|
||||
})
|
||||
}
|
||||
|
||||
public async getModels(includeEmbeddings = false) {
|
||||
await this._ensureDependencies()
|
||||
if (!this.ollama) {
|
||||
throw new Error('Ollama client is not initialized.')
|
||||
}
|
||||
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: 'pulls',
|
||||
recommendedOnly: false,
|
||||
query: null,
|
||||
limit: 15,
|
||||
}
|
||||
): 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
|
||||
}
|
||||
}
|
||||
|
||||
if (!recommendedOnly) {
|
||||
const filteredModels = query ? this.fuseSearchModels(models, query) : models
|
||||
return {
|
||||
models: filteredModels.slice(0, 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,
|
||||
tags: model.tags && model.tags.length > 0 ? [model.tags[0]] : [],
|
||||
}
|
||||
})
|
||||
|
||||
if (query) {
|
||||
const filteredRecommendedModels = this.fuseSearchModels(recommendedModels, query)
|
||||
return {
|
||||
models: filteredRecommendedModels,
|
||||
hasMore: filteredRecommendedModels.length > (limit || 15)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
models: recommendedModels,
|
||||
hasMore: recommendedModels.length > (limit || 15)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[OllamaService] Failed to get available models: ${error instanceof Error ? error.message : error}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async retrieveAndRefreshModels(
|
||||
sort?: 'pulls' | 'name',
|
||||
force?: boolean
|
||||
): Promise<NomadOllamaModel[] | null> {
|
||||
try {
|
||||
if (!force) {
|
||||
const cachedModels = await this.readModelsFromCache()
|
||||
if (cachedModels) {
|
||||
logger.info('[OllamaService] Using cached available models data')
|
||||
return this.sortModels(cachedModels, sort)
|
||||
}
|
||||
} else {
|
||||
logger.info('[OllamaService] Force refresh requested, bypassing cache')
|
||||
}
|
||||
|
||||
logger.info('[OllamaService] Fetching fresh available models from API')
|
||||
|
||||
const baseUrl = env.get('NOMAD_API_URL') || NOMAD_API_DEFAULT_BASE_URL
|
||||
const fullUrl = new URL(NOMAD_MODELS_API_PATH, baseUrl).toString()
|
||||
|
||||
const response = await axios.get(fullUrl)
|
||||
if (!response.data || !Array.isArray(response.data.models)) {
|
||||
logger.warn(
|
||||
`[OllamaService] Invalid response format when fetching available models: ${JSON.stringify(response.data)}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
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,
|
||||
tags: model.tags.filter((tag) => !tag.cloud),
|
||||
}))
|
||||
.filter((model) => model.tags.length > 0)
|
||||
|
||||
await this.writeModelsToCache(noCloud)
|
||||
return this.sortModels(noCloud, sort)
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[OllamaService] Failed to retrieve models from Nomad API: ${error instanceof Error ? error.message : error
|
||||
}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async readModelsFromCache(): Promise<NomadOllamaModel[] | null> {
|
||||
try {
|
||||
const stats = await fs.stat(MODELS_CACHE_FILE)
|
||||
const cacheAge = Date.now() - stats.mtimeMs
|
||||
|
||||
if (cacheAge > CACHE_MAX_AGE_MS) {
|
||||
logger.info('[OllamaService] Cache is stale, will fetch fresh data')
|
||||
return null
|
||||
}
|
||||
|
||||
const cacheData = await fs.readFile(MODELS_CACHE_FILE, 'utf-8')
|
||||
const models = JSON.parse(cacheData) as NomadOllamaModel[]
|
||||
|
||||
if (!Array.isArray(models)) {
|
||||
logger.warn('[OllamaService] Invalid cache format, will fetch fresh data')
|
||||
return null
|
||||
}
|
||||
|
||||
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}`
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async writeModelsToCache(models: NomadOllamaModel[]): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(path.dirname(MODELS_CACHE_FILE), { recursive: true })
|
||||
await fs.writeFile(MODELS_CACHE_FILE, JSON.stringify(models, null, 2), 'utf-8')
|
||||
logger.info('[OllamaService] Successfully cached available models')
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[OllamaService] Failed to write models cache: ${error instanceof Error ? error.message : error}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
? 1_000
|
||||
: pulls.endsWith('M')
|
||||
? 1_000_000
|
||||
: pulls.endsWith('B')
|
||||
? 1_000_000_000
|
||||
: 1
|
||||
return parseFloat(pulls) * multiplier
|
||||
}
|
||||
return parsePulls(b.estimated_pulls) - parsePulls(a.estimated_pulls)
|
||||
})
|
||||
} else if (sort === 'name') {
|
||||
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) => {
|
||||
const parseSize = (size: string) => {
|
||||
const multiplier = size.endsWith('KB')
|
||||
? 1 / 1_000
|
||||
: size.endsWith('MB')
|
||||
? 1 / 1_000_000
|
||||
: size.endsWith('GB')
|
||||
? 1
|
||||
: size.endsWith('TB')
|
||||
? 1_000
|
||||
: 0 // Unknown size format
|
||||
return parseFloat(size) * multiplier
|
||||
}
|
||||
return parseSize(a.size) - parseSize(b.size)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
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,
|
||||
percent,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
logger.info(`[OllamaService] Download progress for model "${model}": ${percent}%`)
|
||||
}
|
||||
|
||||
private fuseSearchModels(models: NomadOllamaModel[], query: string): NomadOllamaModel[] {
|
||||
const options: IFuseOptions<NomadOllamaModel> = {
|
||||
ignoreDiacritics: true,
|
||||
keys: ['name', 'description', 'tags.name'],
|
||||
threshold: 0.3, // lower threshold for stricter matching
|
||||
}
|
||||
|
||||
const fuse = new Fuse(models, options)
|
||||
|
||||
return fuse.search(query).map(result => result.item)
|
||||
}
|
||||
}
|
||||
1212
admin/app/services/rag_service.ts
Normal file
1212
admin/app/services/rag_service.ts
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -4,19 +4,29 @@ import { DockerService } from '#services/docker_service'
|
|||
import { ServiceSlim } from '../../types/services.js'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import si from 'systeminformation'
|
||||
import { 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 { 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'
|
||||
|
||||
|
||||
@inject()
|
||||
export class SystemService {
|
||||
private static appVersion: string | null = null
|
||||
private static diskInfoFile = '/storage/nomad-disk-info.json'
|
||||
|
||||
constructor(private dockerService: DockerService) {}
|
||||
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);
|
||||
}
|
||||
|
||||
async getInternetStatus(): Promise<boolean> {
|
||||
const DEFAULT_TEST_URL = 'https://1.1.1.1/cdn-cgi/trace'
|
||||
|
|
@ -57,10 +67,71 @@ export class SystemService {
|
|||
return false
|
||||
}
|
||||
|
||||
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}`)
|
||||
)
|
||||
if (!ollamaContainer) {
|
||||
logger.info('Ollama container not found for nvidia-smi info retrieval. This is expected if Ollama is not installed.')
|
||||
return 'OLLAMA_NOT_FOUND'
|
||||
}
|
||||
|
||||
// Execute nvidia-smi inside the Ollama container to get GPU info
|
||||
const container = this.dockerService.docker.getContainer(ollamaContainer.Id)
|
||||
const exec = await container.exec({
|
||||
Cmd: ['nvidia-smi', '--query-gpu=name,memory.total', '--format=csv,noheader,nounits'],
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
Tty: true,
|
||||
})
|
||||
|
||||
// Read the output stream with a timeout to prevent hanging if nvidia-smi fails
|
||||
const stream = await exec.start({ Tty: true })
|
||||
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) })
|
||||
})
|
||||
|
||||
// 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')) {
|
||||
// Split by newlines to handle multiple GPUs installed
|
||||
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 parts = line.split(',').map((s) => s.trim())
|
||||
return {
|
||||
vendor: 'NVIDIA',
|
||||
model: parts[0] || 'NVIDIA GPU',
|
||||
vram: parts[1] ? parseInt(parts[1], 10) : 0,
|
||||
}
|
||||
})
|
||||
|
||||
return gpus.length > 0 ? gpus : 'BAD_RESPONSE'
|
||||
}
|
||||
|
||||
// If we got output but looks like an error, consider it a bad response from nvidia-smi
|
||||
return 'BAD_RESPONSE'
|
||||
}
|
||||
catch (error) {
|
||||
logger.error('Error getting nvidia-smi info:', error)
|
||||
if (error instanceof Error && error.message) {
|
||||
return { error: error.message }
|
||||
}
|
||||
return 'UNKNOWN_ERROR'
|
||||
}
|
||||
}
|
||||
|
||||
async getServices({ installedOnly = true }: { installedOnly?: boolean }): Promise<ServiceSlim[]> {
|
||||
await this._syncContainersWithDatabase() // Sync up before fetching to ensure we have the latest status
|
||||
|
||||
const query = Service.query()
|
||||
.orderBy('display_order', 'asc')
|
||||
.orderBy('friendly_name', 'asc')
|
||||
.select(
|
||||
'id',
|
||||
|
|
@ -70,7 +141,11 @@ export class SystemService {
|
|||
'ui_location',
|
||||
'friendly_name',
|
||||
'description',
|
||||
'icon'
|
||||
'icon',
|
||||
'powered_by',
|
||||
'display_order',
|
||||
'container_image',
|
||||
'available_update_version'
|
||||
)
|
||||
.where('is_dependency_service', false)
|
||||
if (installedOnly) {
|
||||
|
|
@ -98,6 +173,10 @@ export class SystemService {
|
|||
installation_status: service.installation_status,
|
||||
status: status ? status.status : 'unknown',
|
||||
ui_location: service.ui_location || '',
|
||||
powered_by: service.powered_by,
|
||||
display_order: service.display_order,
|
||||
container_image: service.container_image,
|
||||
available_update_version: service.available_update_version,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -131,13 +210,14 @@ export class SystemService {
|
|||
|
||||
async getSystemInfo(): Promise<SystemInformationResponse | undefined> {
|
||||
try {
|
||||
const [cpu, mem, os, currentLoad, fsSize, uptime] = await Promise.all([
|
||||
const [cpu, mem, os, currentLoad, fsSize, uptime, graphics] = await Promise.all([
|
||||
si.cpu(),
|
||||
si.mem(),
|
||||
si.osInfo(),
|
||||
si.currentLoad(),
|
||||
si.fsSize(),
|
||||
si.time(),
|
||||
si.graphics(),
|
||||
])
|
||||
|
||||
let diskInfo: NomadDiskInfoRaw | undefined
|
||||
|
|
@ -160,6 +240,61 @@ export class SystemService {
|
|||
logger.error('Error reading disk info file:', error)
|
||||
}
|
||||
|
||||
// GPU health tracking — detect when host has NVIDIA GPU but Ollama can't access it
|
||||
let gpuHealth: GpuHealthStatus = {
|
||||
status: 'no_gpu',
|
||||
hasNvidiaRuntime: false,
|
||||
ollamaGpuAccessible: false,
|
||||
}
|
||||
|
||||
// Query Docker API for host-level info (hostname, OS, GPU runtime)
|
||||
// si.osInfo() returns the container's info inside Docker, not the host's
|
||||
try {
|
||||
const dockerInfo = await this.dockerService.docker.info()
|
||||
|
||||
if (dockerInfo.Name) {
|
||||
os.hostname = dockerInfo.Name
|
||||
}
|
||||
if (dockerInfo.OperatingSystem) {
|
||||
os.distro = dockerInfo.OperatingSystem
|
||||
}
|
||||
if (dockerInfo.KernelVersion) {
|
||||
os.kernel = dockerInfo.KernelVersion
|
||||
}
|
||||
|
||||
// If si.graphics() returned no controllers (common inside Docker),
|
||||
// fall back to nvidia runtime + nvidia-smi detection
|
||||
if (!graphics.controllers || graphics.controllers.length === 0) {
|
||||
const runtimes = dockerInfo.Runtimes || {}
|
||||
if ('nvidia' in runtimes) {
|
||||
gpuHealth.hasNvidiaRuntime = true
|
||||
const nvidiaInfo = await this.getNvidiaSmiInfo()
|
||||
if (Array.isArray(nvidiaInfo)) {
|
||||
graphics.controllers = nvidiaInfo.map((gpu) => ({
|
||||
model: gpu.model,
|
||||
vendor: gpu.vendor,
|
||||
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') {
|
||||
gpuHealth.status = 'ollama_not_installed'
|
||||
} else {
|
||||
gpuHealth.status = 'passthrough_failed'
|
||||
logger.warn(`NVIDIA runtime detected but GPU passthrough failed: ${typeof nvidiaInfo === 'string' ? nvidiaInfo : JSON.stringify(nvidiaInfo)}`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// si.graphics() returned controllers (host install, not Docker) — GPU is working
|
||||
gpuHealth.status = 'ok'
|
||||
gpuHealth.ollamaGpuAccessible = true
|
||||
}
|
||||
} catch {
|
||||
// Docker info query failed, skip host-level enrichment
|
||||
}
|
||||
|
||||
return {
|
||||
cpu,
|
||||
mem,
|
||||
|
|
@ -168,6 +303,8 @@ export class SystemService {
|
|||
currentLoad,
|
||||
fsSize,
|
||||
uptime,
|
||||
graphics,
|
||||
gpuHealth,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error getting system info:', error)
|
||||
|
|
@ -175,7 +312,7 @@ export class SystemService {
|
|||
}
|
||||
}
|
||||
|
||||
async checkLatestVersion(): Promise<{
|
||||
async checkLatestVersion(force?: boolean): Promise<{
|
||||
success: boolean
|
||||
updateAvailable: boolean
|
||||
currentVersion: string
|
||||
|
|
@ -183,25 +320,49 @@ export class SystemService {
|
|||
message?: string
|
||||
}> {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
'https://api.github.com/repos/Crosstalk-Solutions/project-nomad/releases/latest',
|
||||
{
|
||||
headers: { Accept: 'application/vnd.github+json' },
|
||||
timeout: 5000,
|
||||
}
|
||||
)
|
||||
const currentVersion = SystemService.getAppVersion()
|
||||
const cachedUpdateAvailable = await KVStore.getValue('system.updateAvailable')
|
||||
const cachedLatestVersion = await KVStore.getValue('system.latestVersion')
|
||||
|
||||
if (!response || !response.data?.tag_name) {
|
||||
throw new Error('Invalid response from GitHub API')
|
||||
// Use cached values if not forcing a fresh check.
|
||||
// the CheckUpdateJob will update these values every 12 hours
|
||||
if (!force) {
|
||||
return {
|
||||
success: true,
|
||||
updateAvailable: cachedUpdateAvailable ?? false,
|
||||
currentVersion,
|
||||
latestVersion: cachedLatestVersion || '',
|
||||
}
|
||||
}
|
||||
|
||||
const latestVersion = response.data.tag_name.replace(/^v/, '') // Remove leading 'v' if present
|
||||
const currentVersion = SystemService.getAppVersion()
|
||||
const earlyAccess = (await KVStore.getValue('system.earlyAccess')) ?? false
|
||||
|
||||
let latestVersion: string
|
||||
if (earlyAccess) {
|
||||
const response = await axios.get(
|
||||
'https://api.github.com/repos/Crosstalk-Solutions/project-nomad/releases',
|
||||
{ headers: { Accept: 'application/vnd.github+json' }, timeout: 5000 }
|
||||
)
|
||||
if (!response?.data?.length) throw new Error('No releases found')
|
||||
latestVersion = response.data[0].tag_name.replace(/^v/, '').trim()
|
||||
} else {
|
||||
const response = await axios.get(
|
||||
'https://api.github.com/repos/Crosstalk-Solutions/project-nomad/releases/latest',
|
||||
{ headers: { Accept: 'application/vnd.github+json' }, timeout: 5000 }
|
||||
)
|
||||
if (!response?.data?.tag_name) throw new Error('Invalid response from GitHub API')
|
||||
latestVersion = response.data.tag_name.replace(/^v/, '').trim()
|
||||
}
|
||||
|
||||
logger.info(`Current version: ${currentVersion}, Latest version: ${latestVersion}`)
|
||||
|
||||
// NOTE: this will always return true in dev environment! See getAppVersion()
|
||||
const updateAvailable = latestVersion !== currentVersion
|
||||
const updateAvailable = process.env.NODE_ENV === 'development'
|
||||
? false
|
||||
: isNewerVersion(latestVersion, currentVersion.trim(), earlyAccess)
|
||||
|
||||
// Cache the results in KVStore for frontend checks
|
||||
await KVStore.setValue('system.updateAvailable', updateAvailable)
|
||||
await KVStore.setValue('system.latestVersion', latestVersion)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -221,6 +382,153 @@ export class SystemService {
|
|||
}
|
||||
}
|
||||
|
||||
async subscribeToReleaseNotes(email: string): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
'https://api.projectnomad.us/api/v1/lists/release-notes/subscribe',
|
||||
{ email },
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
if (response.status === 200) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'Successfully subscribed to release notes',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to subscribe: ${response.statusText}`,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error subscribing to release notes:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to subscribe: ${error instanceof Error ? error.message : error}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 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') {
|
||||
await KVStore.clearValue(key)
|
||||
} else {
|
||||
await KVStore.setValue(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the current state of Docker containers against the database records and updates the database accordingly.
|
||||
* It will mark services as not installed if their corresponding containers do not exist, regardless of their running state.
|
||||
|
|
@ -236,7 +544,7 @@ export class SystemService {
|
|||
const containerExists = serviceStatusList.find(
|
||||
(s) => s.service_name === service.service_name
|
||||
)
|
||||
|
||||
|
||||
if (service.installed) {
|
||||
// If marked as installed but container doesn't exist, mark as not installed
|
||||
if (!containerExists) {
|
||||
|
|
@ -271,10 +579,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)
|
||||
|
|
@ -301,4 +620,5 @@ export class SystemService {
|
|||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import logger from '@adonisjs/core/services/logger'
|
|||
import { readFileSync, existsSync } from 'fs'
|
||||
import { writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import KVStore from '#models/kv_store'
|
||||
|
||||
interface UpdateStatus {
|
||||
stage: 'idle' | 'starting' | 'pulling' | 'pulled' | 'recreating' | 'complete' | 'error'
|
||||
|
|
@ -21,7 +22,7 @@ export class SystemUpdateService {
|
|||
*/
|
||||
async requestUpdate(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const currentStatus = this.getUpdateStatus()
|
||||
const currentStatus = this.getUpdateStatus()
|
||||
if (currentStatus && !['idle', 'complete', 'error'].includes(currentStatus.stage)) {
|
||||
return {
|
||||
success: false,
|
||||
|
|
@ -29,13 +30,17 @@ export class SystemUpdateService {
|
|||
}
|
||||
}
|
||||
|
||||
// Determine the Docker image tag to install.
|
||||
const latestVersion = await KVStore.getValue('system.latestVersion')
|
||||
|
||||
const requestData = {
|
||||
requested_at: new Date().toISOString(),
|
||||
requester: 'admin-api',
|
||||
target_tag: latestVersion ? `v${latestVersion}` : 'latest',
|
||||
}
|
||||
|
||||
await writeFile(SystemUpdateService.REQUEST_FILE, JSON.stringify(requestData, null, 2))
|
||||
logger.info('[SystemUpdateService]: System update requested - sidecar will process shortly')
|
||||
logger.info(`[SystemUpdateService]: System update requested (target tag: ${requestData.target_tag}) - sidecar will process shortly`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
|
|||
310
admin/app/services/zim_extraction_service.ts
Normal file
310
admin/app/services/zim_extraction_service.ts
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
import { Archive, Entry } from '@openzim/libzim'
|
||||
import * as cheerio from 'cheerio'
|
||||
import { HTML_SELECTORS_TO_REMOVE, NON_CONTENT_HEADING_PATTERNS } from '../../constants/zim_extraction.js'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import { ExtractZIMChunkingStrategy, ExtractZIMContentOptions, ZIMContentChunk, ZIMArchiveMetadata } from '../../types/zim.js'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { access } from 'node:fs/promises'
|
||||
|
||||
export class ZIMExtractionService {
|
||||
|
||||
private extractArchiveMetadata(archive: Archive): ZIMArchiveMetadata {
|
||||
try {
|
||||
return {
|
||||
title: archive.getMetadata('Title') || archive.getMetadata('Name') || 'Unknown',
|
||||
creator: archive.getMetadata('Creator') || 'Unknown',
|
||||
publisher: archive.getMetadata('Publisher') || 'Unknown',
|
||||
date: archive.getMetadata('Date') || 'Unknown',
|
||||
language: archive.getMetadata('Language') || 'Unknown',
|
||||
description: archive.getMetadata('Description') || '',
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('[ZIMExtractionService]: Could not extract all metadata, using defaults', error)
|
||||
return {
|
||||
title: 'Unknown',
|
||||
creator: 'Unknown',
|
||||
publisher: 'Unknown',
|
||||
date: 'Unknown',
|
||||
language: 'Unknown',
|
||||
description: '',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Breaks out a ZIM file's entries into their structured content form
|
||||
* to facilitate better indexing and retrieval.
|
||||
* Returns enhanced chunks with full article context and metadata.
|
||||
*
|
||||
* @param filePath - Path to the ZIM file
|
||||
* @param opts - Options including maxArticles, strategy, onProgress, startOffset, and batchSize
|
||||
*/
|
||||
async extractZIMContent(filePath: string, opts: ExtractZIMContentOptions = {}): Promise<ZIMContentChunk[]> {
|
||||
try {
|
||||
logger.info(`[ZIMExtractionService]: Processing ZIM file at path: ${filePath}`)
|
||||
|
||||
// defensive - check if file still exists before opening
|
||||
// could have been deleted by another process or batch
|
||||
try {
|
||||
await access(filePath)
|
||||
} catch (error) {
|
||||
logger.error(`[ZIMExtractionService]: ZIM file not accessible: ${filePath}`)
|
||||
throw new Error(`ZIM file not found or not accessible: ${filePath}`)
|
||||
}
|
||||
|
||||
const archive = new Archive(filePath)
|
||||
|
||||
// Extract archive-level metadata once
|
||||
const archiveMetadata = this.extractArchiveMetadata(archive)
|
||||
logger.info(`[ZIMExtractionService]: Archive metadata - Title: ${archiveMetadata.title}, Language: ${archiveMetadata.language}`)
|
||||
|
||||
let articlesProcessed = 0
|
||||
let articlesSkipped = 0
|
||||
const processedPaths = new Set<string>()
|
||||
const toReturn: ZIMContentChunk[] = []
|
||||
|
||||
// Support batch processing to avoid lock timeouts on large ZIM files
|
||||
const startOffset = opts.startOffset || 0
|
||||
const batchSize = opts.batchSize || (opts.maxArticles || Infinity)
|
||||
|
||||
for (const entry of archive.iterByPath()) {
|
||||
// Skip articles until we reach the start offset
|
||||
if (articlesSkipped < startOffset) {
|
||||
if (this.isArticleEntry(entry) && !processedPaths.has(entry.path)) {
|
||||
articlesSkipped++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (articlesProcessed >= batchSize) {
|
||||
break
|
||||
}
|
||||
|
||||
if (!this.isArticleEntry(entry)) {
|
||||
logger.debug(`[ZIMExtractionService]: Skipping non-article entry at path: ${entry.path}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (processedPaths.has(entry.path)) {
|
||||
logger.debug(`[ZIMExtractionService]: Skipping duplicate entry at path: ${entry.path}`)
|
||||
continue
|
||||
}
|
||||
processedPaths.add(entry.path)
|
||||
|
||||
const item = entry.item
|
||||
const blob = item.data
|
||||
const html = this.getCleanedHTMLString(blob.data)
|
||||
|
||||
const strategy = opts.strategy || this.chooseChunkingStrategy(html);
|
||||
logger.debug(`[ZIMExtractionService]: Chosen chunking strategy for path ${entry.path}: ${strategy}`)
|
||||
|
||||
// Generate a unique document ID. All chunks from same article will share it
|
||||
const documentId = randomUUID()
|
||||
const articleTitle = entry.title || entry.path
|
||||
|
||||
let chunks: ZIMContentChunk[]
|
||||
|
||||
if (strategy === 'structured') {
|
||||
const structured = this.extractStructuredContent(html)
|
||||
chunks = structured.sections.map(s => ({
|
||||
text: s.text,
|
||||
articleTitle,
|
||||
articlePath: entry.path,
|
||||
sectionTitle: s.heading,
|
||||
fullTitle: `${articleTitle} - ${s.heading}`,
|
||||
hierarchy: `${articleTitle} > ${s.heading}`,
|
||||
sectionLevel: s.level,
|
||||
documentId,
|
||||
archiveMetadata,
|
||||
strategy,
|
||||
}))
|
||||
} else {
|
||||
// Simple strategy - entire article as one chunk
|
||||
const text = this.extractTextFromHTML(html) || ''
|
||||
chunks = [{
|
||||
text,
|
||||
articleTitle,
|
||||
articlePath: entry.path,
|
||||
sectionTitle: articleTitle, // Same as article for simple strategy
|
||||
fullTitle: articleTitle,
|
||||
hierarchy: articleTitle,
|
||||
documentId,
|
||||
archiveMetadata,
|
||||
strategy,
|
||||
}]
|
||||
}
|
||||
|
||||
logger.debug(`Extracted ${chunks.length} chunks from article at path: ${entry.path} using strategy: ${strategy}`)
|
||||
|
||||
const nonEmptyChunks = chunks.filter(c => c.text.trim().length > 0)
|
||||
logger.debug(`After filtering empty chunks, ${nonEmptyChunks.length} chunks remain for article at path: ${entry.path}`)
|
||||
toReturn.push(...nonEmptyChunks)
|
||||
articlesProcessed++
|
||||
|
||||
if (opts.onProgress) {
|
||||
opts.onProgress(articlesProcessed, archive.articleCount)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[ZIMExtractionService]: Completed processing ZIM file. Total articles processed: ${articlesProcessed}`)
|
||||
logger.debug("Final structured content sample:", toReturn.slice(0, 3).map(c => ({
|
||||
articleTitle: c.articleTitle,
|
||||
sectionTitle: c.sectionTitle,
|
||||
hierarchy: c.hierarchy,
|
||||
textPreview: c.text.substring(0, 100)
|
||||
})))
|
||||
logger.debug("Total structured sections extracted:", toReturn.length)
|
||||
return toReturn
|
||||
} catch (error) {
|
||||
logger.error('Error processing ZIM file:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private chooseChunkingStrategy(html: string, options = {
|
||||
forceStrategy: null as ExtractZIMChunkingStrategy | null,
|
||||
}): ExtractZIMChunkingStrategy {
|
||||
const {
|
||||
forceStrategy = null,
|
||||
} = options;
|
||||
|
||||
if (forceStrategy) return forceStrategy;
|
||||
|
||||
// Use a simple analysis to determin if the HTML has any meaningful structure
|
||||
// that we can leverage for better chunking. If not, we'll just chunk it as one big piece of text.
|
||||
return this.hasStructuredHeadings(html) ? 'structured' : 'simple';
|
||||
}
|
||||
|
||||
private getCleanedHTMLString(buff: Buffer<ArrayBufferLike>): string {
|
||||
const rawString = buff.toString('utf-8');
|
||||
const $ = cheerio.load(rawString);
|
||||
|
||||
HTML_SELECTORS_TO_REMOVE.forEach((selector) => {
|
||||
$(selector).remove()
|
||||
});
|
||||
|
||||
return $.html();
|
||||
}
|
||||
|
||||
private extractTextFromHTML(html: string): string | null {
|
||||
try {
|
||||
const $ = cheerio.load(html)
|
||||
|
||||
// Search body first, then root if body is absent
|
||||
const text = $('body').length ? $('body').text() : $.root().text()
|
||||
|
||||
return text.replace(/\s+/g, ' ').replace(/\n\s*\n/g, '\n').trim()
|
||||
} catch (error) {
|
||||
logger.error('Error extracting text from HTML:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private extractStructuredContent(html: string) {
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const title = $('h1').first().text().trim() || $('title').text().trim();
|
||||
|
||||
// Extract sections with their headings and heading levels
|
||||
const sections: Array<{ heading: string; text: string; level: number }> = [];
|
||||
let currentSection = { heading: 'Introduction', content: [] as string[], level: 2 };
|
||||
|
||||
$('body').children().each((_, element) => {
|
||||
const $el = $(element);
|
||||
const tagName = element.tagName?.toLowerCase();
|
||||
|
||||
if (['h2', 'h3', 'h4'].includes(tagName)) {
|
||||
// Save current section if it has content
|
||||
if (currentSection.content.length > 0) {
|
||||
sections.push({
|
||||
heading: currentSection.heading,
|
||||
text: currentSection.content.join(' ').replace(/\s+/g, ' ').trim(),
|
||||
level: currentSection.level,
|
||||
});
|
||||
}
|
||||
// Start new section
|
||||
const level = parseInt(tagName.substring(1)); // Extract number from h2, h3, h4
|
||||
currentSection = {
|
||||
heading: $el.text().replace(/\[edit\]/gi, '').trim(),
|
||||
content: [],
|
||||
level,
|
||||
};
|
||||
} else if (['p', 'ul', 'ol', 'dl', 'table'].includes(tagName)) {
|
||||
const text = $el.text().trim();
|
||||
if (text.length > 0) {
|
||||
currentSection.content.push(text);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Push the last section if it has content
|
||||
if (currentSection.content.length > 0) {
|
||||
sections.push({
|
||||
heading: currentSection.heading,
|
||||
text: currentSection.content.join(' ').replace(/\s+/g, ' ').trim(),
|
||||
level: currentSection.level,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
sections,
|
||||
fullText: sections.map(s => `${s.heading}\n${s.text}`).join('\n\n'),
|
||||
};
|
||||
}
|
||||
|
||||
private hasStructuredHeadings(html: string): boolean {
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const headings = $('h2, h3').toArray();
|
||||
|
||||
// Consider it structured if it has at least 2 headings to break content into meaningful sections
|
||||
if (headings.length < 2) return false;
|
||||
|
||||
// Check that headings have substantial content between them
|
||||
let sectionsWithContent = 0;
|
||||
|
||||
for (const heading of headings) {
|
||||
const $heading = $(heading);
|
||||
const headingText = $heading.text().trim();
|
||||
|
||||
// Skip empty or very short headings, likely not meaningful
|
||||
if (headingText.length < 3) continue;
|
||||
|
||||
// Skip common non-content headings
|
||||
if (NON_CONTENT_HEADING_PATTERNS.some(pattern => pattern.test(headingText))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Content until next heading
|
||||
let contentLength = 0;
|
||||
let $next = $heading.next();
|
||||
|
||||
while ($next.length && !$next.is('h1, h2, h3, h4')) {
|
||||
contentLength += $next.text().trim().length;
|
||||
$next = $next.next();
|
||||
}
|
||||
|
||||
// Consider it a real section if it has at least 100 chars of content
|
||||
if (contentLength >= 100) {
|
||||
sectionsWithContent++;
|
||||
}
|
||||
}
|
||||
|
||||
// Require at least 2 sections with substantial content
|
||||
return sectionsWithContent >= 2;
|
||||
}
|
||||
|
||||
private isArticleEntry(entry: Entry): boolean {
|
||||
try {
|
||||
if (entry.isRedirect) return false;
|
||||
|
||||
const item = entry.item;
|
||||
const mimeType = item.mimetype;
|
||||
|
||||
return mimeType === 'text/html' || mimeType === 'application/xhtml+xml';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -16,28 +16,23 @@ import {
|
|||
listDirectoryContents,
|
||||
ZIM_STORAGE_PATH,
|
||||
} from '../utils/fs.js'
|
||||
import { join } from 'path'
|
||||
import { CuratedCollectionWithStatus, CuratedCollectionsFile } from '../../types/downloads.js'
|
||||
import { join, resolve, sep } from 'path'
|
||||
import { WikipediaOption, WikipediaState } from '../../types/downloads.js'
|
||||
import vine from '@vinejs/vine'
|
||||
import { curatedCollectionsFileSchema } from '#validators/curated_collections'
|
||||
import CuratedCollection from '#models/curated_collection'
|
||||
import CuratedCollectionResource from '#models/curated_collection_resource'
|
||||
import { wikipediaOptionsFileSchema } from '#validators/curated_collections'
|
||||
import WikipediaSelection from '#models/wikipedia_selection'
|
||||
import InstalledResource from '#models/installed_resource'
|
||||
import { RunDownloadJob } from '#jobs/run_download_job'
|
||||
import { DownloadCollectionOperation, DownloadRemoteSuccessCallback } from '../../types/files.js'
|
||||
import { SERVICE_NAMES } from '../../constants/service_names.js'
|
||||
import { CollectionManifestService } from './collection_manifest_service.js'
|
||||
import type { CategoryWithStatus } from '../../types/collections.js'
|
||||
|
||||
const ZIM_MIME_TYPES = ['application/x-zim', 'application/x-openzim', 'application/octet-stream']
|
||||
const COLLECTIONS_URL =
|
||||
'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/master/collections/kiwix.json'
|
||||
|
||||
|
||||
interface IZimService {
|
||||
downloadCollection: DownloadCollectionOperation
|
||||
downloadRemoteSuccessCallback: DownloadRemoteSuccessCallback
|
||||
}
|
||||
const WIKIPEDIA_OPTIONS_URL = 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/wikipedia.json'
|
||||
|
||||
@inject()
|
||||
export class ZimService implements IZimService {
|
||||
constructor(private dockerService: DockerService) {}
|
||||
export class ZimService {
|
||||
constructor(private dockerService: DockerService) { }
|
||||
|
||||
async list() {
|
||||
const dirPath = join(process.cwd(), ZIM_STORAGE_PATH)
|
||||
|
|
@ -161,6 +156,12 @@ export class ZimService implements IZimService {
|
|||
|
||||
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
|
||||
|
||||
// Parse resource metadata for the download job
|
||||
const parsedFilename = CollectionManifestService.parseZimFilename(filename)
|
||||
const resourceMetadata = parsedFilename
|
||||
? { resource_id: parsedFilename.resource_id, version: parsedFilename.version, collection_ref: null }
|
||||
: undefined
|
||||
|
||||
// Dispatch a background download job
|
||||
const result = await RunDownloadJob.dispatch({
|
||||
url,
|
||||
|
|
@ -169,6 +170,7 @@ export class ZimService implements IZimService {
|
|||
allowedMimeTypes: ZIM_MIME_TYPES,
|
||||
forceNew: true,
|
||||
filetype: 'zim',
|
||||
resourceMetadata,
|
||||
})
|
||||
|
||||
if (!result || !result.job) {
|
||||
|
|
@ -183,105 +185,144 @@ export class ZimService implements IZimService {
|
|||
}
|
||||
}
|
||||
|
||||
async downloadCollection(slug: string) {
|
||||
const collection = await CuratedCollection.query().where('slug', slug).andWhere('type', 'zim').first()
|
||||
if (!collection) {
|
||||
return null
|
||||
async listCuratedCategories(): Promise<CategoryWithStatus[]> {
|
||||
const manifestService = new CollectionManifestService()
|
||||
return manifestService.getCategoriesWithStatus()
|
||||
}
|
||||
|
||||
async downloadCategoryTier(categorySlug: string, tierSlug: string): Promise<string[] | null> {
|
||||
const manifestService = new CollectionManifestService()
|
||||
const spec = await manifestService.getSpecWithFallback<import('../../types/collections.js').ZimCategoriesSpec>('zim_categories')
|
||||
if (!spec) {
|
||||
throw new Error('Could not load ZIM categories spec')
|
||||
}
|
||||
|
||||
const resources = await collection.related('resources').query().where('downloaded', false)
|
||||
if (resources.length === 0) {
|
||||
return null
|
||||
const category = spec.categories.find((c) => c.slug === categorySlug)
|
||||
if (!category) {
|
||||
throw new Error(`Category not found: ${categorySlug}`)
|
||||
}
|
||||
|
||||
const downloadUrls = resources.map((res) => res.url)
|
||||
const tier = category.tiers.find((t) => t.slug === tierSlug)
|
||||
if (!tier) {
|
||||
throw new Error(`Tier not found: ${tierSlug}`)
|
||||
}
|
||||
|
||||
const allResources = CollectionManifestService.resolveTierResources(tier, category.tiers)
|
||||
|
||||
// Filter out already installed
|
||||
const installed = await InstalledResource.query().where('resource_type', 'zim')
|
||||
const installedIds = new Set(installed.map((r) => r.resource_id))
|
||||
const toDownload = allResources.filter((r) => !installedIds.has(r.id))
|
||||
|
||||
if (toDownload.length === 0) return null
|
||||
|
||||
const downloadFilenames: string[] = []
|
||||
|
||||
for (const url of downloadUrls) {
|
||||
const existing = await RunDownloadJob.getByUrl(url)
|
||||
if (existing) {
|
||||
logger.warn(`[ZimService] Download already in progress for URL ${url}, skipping.`)
|
||||
for (const resource of toDownload) {
|
||||
const existingJob = await RunDownloadJob.getByUrl(resource.url)
|
||||
if (existingJob) {
|
||||
logger.warn(`[ZimService] Download already in progress for ${resource.url}, skipping.`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract the filename from the URL
|
||||
const filename = url.split('/').pop()
|
||||
if (!filename) {
|
||||
logger.warn(`[ZimService] Could not determine filename from URL ${url}, skipping.`)
|
||||
continue
|
||||
}
|
||||
const filename = resource.url.split('/').pop()
|
||||
if (!filename) continue
|
||||
|
||||
downloadFilenames.push(filename)
|
||||
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
|
||||
|
||||
await RunDownloadJob.dispatch({
|
||||
url,
|
||||
url: resource.url,
|
||||
filepath,
|
||||
timeout: 30000,
|
||||
allowedMimeTypes: ZIM_MIME_TYPES,
|
||||
forceNew: true,
|
||||
filetype: 'zim',
|
||||
resourceMetadata: {
|
||||
resource_id: resource.id,
|
||||
version: resource.version,
|
||||
collection_ref: categorySlug,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return downloadFilenames.length > 0 ? downloadFilenames : null
|
||||
}
|
||||
}
|
||||
|
||||
async downloadRemoteSuccessCallback(urls: string[], restart = true) {
|
||||
// Restart KIWIX container to pick up new ZIM file
|
||||
// Check if any URL is a Wikipedia download and handle it
|
||||
for (const url of urls) {
|
||||
if (url.includes('wikipedia_en_')) {
|
||||
await this.onWikipediaDownloadComplete(url, true)
|
||||
}
|
||||
}
|
||||
|
||||
if (restart) {
|
||||
await this.dockerService
|
||||
.affectContainer(DockerService.KIWIX_SERVICE_NAME, 'restart')
|
||||
.catch((error) => {
|
||||
logger.error(`[ZimService] Failed to restart KIWIX container:`, error) // Don't stop the download completion, just log the error.
|
||||
})
|
||||
}
|
||||
// Check if there are any remaining ZIM download jobs before restarting
|
||||
const { QueueService } = await import('./queue_service.js')
|
||||
const queueService = new QueueService()
|
||||
const queue = queueService.getQueue('downloads')
|
||||
|
||||
// Mark any curated collection resources with this download URL as downloaded
|
||||
const resources = await CuratedCollectionResource.query().whereIn('url', urls)
|
||||
for (const resource of resources) {
|
||||
resource.downloaded = true
|
||||
await resource.save()
|
||||
}
|
||||
}
|
||||
// Get all active and waiting jobs
|
||||
const [activeJobs, waitingJobs] = await Promise.all([
|
||||
queue.getActive(),
|
||||
queue.getWaiting(),
|
||||
])
|
||||
|
||||
async listCuratedCollections(): Promise<CuratedCollectionWithStatus[]> {
|
||||
const collections = await CuratedCollection.query().where('type', 'zim').preload('resources')
|
||||
return collections.map((collection) => ({
|
||||
...(collection.serialize() as CuratedCollection),
|
||||
all_downloaded: collection.resources.every((res) => res.downloaded),
|
||||
}))
|
||||
}
|
||||
|
||||
async fetchLatestCollections(): Promise<boolean> {
|
||||
try {
|
||||
const response = await axios.get<CuratedCollectionsFile>(COLLECTIONS_URL)
|
||||
|
||||
const validated = await vine.validate({
|
||||
schema: curatedCollectionsFileSchema,
|
||||
data: response.data,
|
||||
// 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
|
||||
return progress < 100
|
||||
})
|
||||
|
||||
for (const collection of validated.collections) {
|
||||
const collectionResult = await CuratedCollection.updateOrCreate(
|
||||
{ slug: collection.slug },
|
||||
// Check if any remaining incomplete jobs are ZIM downloads
|
||||
const allJobs = [...activeIncompleteJobs, ...waitingJobs]
|
||||
const hasRemainingZimJobs = allJobs.some((job) => job.data.filetype === 'zim')
|
||||
|
||||
if (hasRemainingZimJobs) {
|
||||
logger.info('[ZimService] Skipping container restart - more ZIM downloads pending')
|
||||
} else {
|
||||
// Restart KIWIX container to pick up new ZIM file
|
||||
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.
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Create InstalledResource entries for downloaded files
|
||||
for (const url of urls) {
|
||||
// Skip Wikipedia files (managed separately)
|
||||
if (url.includes('wikipedia_en_')) continue
|
||||
|
||||
const filename = url.split('/').pop()
|
||||
if (!filename) continue
|
||||
|
||||
const parsed = CollectionManifestService.parseZimFilename(filename)
|
||||
if (!parsed) continue
|
||||
|
||||
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
|
||||
const stats = await getFileStatsIfExists(filepath)
|
||||
|
||||
try {
|
||||
const { DateTime } = await import('luxon')
|
||||
await InstalledResource.updateOrCreate(
|
||||
{ resource_id: parsed.resource_id, resource_type: 'zim' },
|
||||
{
|
||||
...collection,
|
||||
type: 'zim',
|
||||
version: parsed.version,
|
||||
url: url,
|
||||
file_path: filepath,
|
||||
file_size_bytes: stats ? Number(stats.size) : null,
|
||||
installed_at: DateTime.now(),
|
||||
}
|
||||
)
|
||||
logger.info(`[ZimService] Upserted curated collection: ${collection.slug}`)
|
||||
|
||||
await collectionResult.related('resources').createMany(collection.resources)
|
||||
logger.info(
|
||||
`[ZimService] Upserted ${collection.resources.length} resources for collection: ${collection.slug}`
|
||||
)
|
||||
logger.info(`[ZimService] Created InstalledResource entry for: ${parsed.resource_id}`)
|
||||
} catch (error) {
|
||||
logger.error(`[ZimService] Failed to create InstalledResource for ${filename}:`, error)
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error(`[ZimService] Failed to download latest Kiwix collections:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -291,7 +332,13 @@ export class ZimService implements IZimService {
|
|||
fileName += '.zim'
|
||||
}
|
||||
|
||||
const fullPath = join(process.cwd(), ZIM_STORAGE_PATH, fileName)
|
||||
const basePath = resolve(join(process.cwd(), ZIM_STORAGE_PATH))
|
||||
const fullPath = resolve(join(basePath, fileName))
|
||||
|
||||
// Prevent path traversal — resolved path must stay within the storage directory
|
||||
if (!fullPath.startsWith(basePath + sep)) {
|
||||
throw new Error('Invalid filename')
|
||||
}
|
||||
|
||||
const exists = await getFileStatsIfExists(fullPath)
|
||||
if (!exists) {
|
||||
|
|
@ -299,5 +346,213 @@ export class ZimService implements IZimService {
|
|||
}
|
||||
|
||||
await deleteFileIfExists(fullPath)
|
||||
|
||||
// Clean up InstalledResource entry
|
||||
const parsed = CollectionManifestService.parseZimFilename(fileName)
|
||||
if (parsed) {
|
||||
await InstalledResource.query()
|
||||
.where('resource_id', parsed.resource_id)
|
||||
.where('resource_type', 'zim')
|
||||
.delete()
|
||||
logger.info(`[ZimService] Deleted InstalledResource entry for: ${parsed.resource_id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Wikipedia selector methods
|
||||
|
||||
async getWikipediaOptions(): Promise<WikipediaOption[]> {
|
||||
try {
|
||||
const response = await axios.get(WIKIPEDIA_OPTIONS_URL)
|
||||
const data = response.data
|
||||
|
||||
const validated = await vine.validate({
|
||||
schema: wikipediaOptionsFileSchema,
|
||||
data,
|
||||
})
|
||||
|
||||
return validated.options
|
||||
} catch (error) {
|
||||
logger.error(`[ZimService] Failed to fetch Wikipedia options:`, error)
|
||||
throw new Error('Failed to fetch Wikipedia options')
|
||||
}
|
||||
}
|
||||
|
||||
async getWikipediaSelection(): Promise<WikipediaSelection | null> {
|
||||
// Get the single row from wikipedia_selections (there should only ever be one)
|
||||
return WikipediaSelection.query().first()
|
||||
}
|
||||
|
||||
async getWikipediaState(): Promise<WikipediaState> {
|
||||
const options = await this.getWikipediaOptions()
|
||||
const selection = await this.getWikipediaSelection()
|
||||
|
||||
return {
|
||||
options,
|
||||
currentSelection: selection
|
||||
? {
|
||||
optionId: selection.option_id,
|
||||
status: selection.status,
|
||||
filename: selection.filename,
|
||||
url: selection.url,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
async selectWikipedia(optionId: string): Promise<{ success: boolean; jobId?: string; message?: string }> {
|
||||
const options = await this.getWikipediaOptions()
|
||||
const selectedOption = options.find((opt) => opt.id === optionId)
|
||||
|
||||
if (!selectedOption) {
|
||||
throw new Error(`Invalid Wikipedia option: ${optionId}`)
|
||||
}
|
||||
|
||||
const currentSelection = await this.getWikipediaSelection()
|
||||
|
||||
// If same as currently installed, no action needed
|
||||
if (currentSelection?.option_id === optionId && currentSelection.status === 'installed') {
|
||||
return { success: true, message: 'Already installed' }
|
||||
}
|
||||
|
||||
// Handle "none" option - delete current Wikipedia file and update DB
|
||||
if (optionId === 'none') {
|
||||
if (currentSelection?.filename) {
|
||||
try {
|
||||
await this.delete(currentSelection.filename)
|
||||
logger.info(`[ZimService] Deleted Wikipedia file: ${currentSelection.filename}`)
|
||||
} catch (error) {
|
||||
// File might already be deleted, that's OK
|
||||
logger.warn(`[ZimService] Could not delete Wikipedia file (may already be gone): ${currentSelection.filename}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Update or create the selection record (always use first record)
|
||||
if (currentSelection) {
|
||||
currentSelection.option_id = 'none'
|
||||
currentSelection.url = null
|
||||
currentSelection.filename = null
|
||||
currentSelection.status = 'none'
|
||||
await currentSelection.save()
|
||||
} else {
|
||||
await WikipediaSelection.create({
|
||||
option_id: 'none',
|
||||
url: null,
|
||||
filename: null,
|
||||
status: 'none',
|
||||
})
|
||||
}
|
||||
|
||||
// Restart Kiwix to reflect the change
|
||||
await this.dockerService
|
||||
.affectContainer(SERVICE_NAMES.KIWIX, 'restart')
|
||||
.catch((error) => {
|
||||
logger.error(`[ZimService] Failed to restart Kiwix after Wikipedia removal:`, error)
|
||||
})
|
||||
|
||||
return { success: true, message: 'Wikipedia removed' }
|
||||
}
|
||||
|
||||
// Start download for the new Wikipedia option
|
||||
if (!selectedOption.url) {
|
||||
throw new Error('Selected Wikipedia option has no download URL')
|
||||
}
|
||||
|
||||
// Check if already downloading
|
||||
const existingJob = await RunDownloadJob.getByUrl(selectedOption.url)
|
||||
if (existingJob) {
|
||||
return { success: false, message: 'Download already in progress' }
|
||||
}
|
||||
|
||||
// Extract filename from URL
|
||||
const filename = selectedOption.url.split('/').pop()
|
||||
if (!filename) {
|
||||
throw new Error('Could not determine filename from URL')
|
||||
}
|
||||
|
||||
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
|
||||
|
||||
// Update or create selection record to show downloading status
|
||||
let selection: WikipediaSelection
|
||||
if (currentSelection) {
|
||||
currentSelection.option_id = optionId
|
||||
currentSelection.url = selectedOption.url
|
||||
currentSelection.filename = filename
|
||||
currentSelection.status = 'downloading'
|
||||
await currentSelection.save()
|
||||
selection = currentSelection
|
||||
} else {
|
||||
selection = await WikipediaSelection.create({
|
||||
option_id: optionId,
|
||||
url: selectedOption.url,
|
||||
filename: filename,
|
||||
status: 'downloading',
|
||||
})
|
||||
}
|
||||
|
||||
// Dispatch download job
|
||||
const result = await RunDownloadJob.dispatch({
|
||||
url: selectedOption.url,
|
||||
filepath,
|
||||
timeout: 30000,
|
||||
allowedMimeTypes: ZIM_MIME_TYPES,
|
||||
forceNew: true,
|
||||
filetype: 'zim',
|
||||
})
|
||||
|
||||
if (!result || !result.job) {
|
||||
// Revert status on failure to dispatch
|
||||
selection.option_id = currentSelection?.option_id || 'none'
|
||||
selection.url = currentSelection?.url || null
|
||||
selection.filename = currentSelection?.filename || null
|
||||
selection.status = currentSelection?.status || 'none'
|
||||
await selection.save()
|
||||
throw new Error('Failed to dispatch download job')
|
||||
}
|
||||
|
||||
logger.info(`[ZimService] Started Wikipedia download for ${optionId}: ${filename}`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
jobId: result.job.id,
|
||||
message: 'Download started',
|
||||
}
|
||||
}
|
||||
|
||||
async onWikipediaDownloadComplete(url: string, success: boolean): Promise<void> {
|
||||
const selection = await this.getWikipediaSelection()
|
||||
|
||||
if (!selection || selection.url !== url) {
|
||||
logger.warn(`[ZimService] Wikipedia download complete callback for unknown URL: ${url}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (success) {
|
||||
// Update status to installed
|
||||
selection.status = 'installed'
|
||||
await selection.save()
|
||||
|
||||
logger.info(`[ZimService] Wikipedia download completed successfully: ${selection.filename}`)
|
||||
|
||||
// Delete the old Wikipedia file if it exists and is different
|
||||
// We need to find what was previously installed
|
||||
const existingFiles = await this.list()
|
||||
const wikipediaFiles = existingFiles.files.filter((f) =>
|
||||
f.name.startsWith('wikipedia_en_') && f.name !== selection.filename
|
||||
)
|
||||
|
||||
for (const oldFile of wikipediaFiles) {
|
||||
try {
|
||||
await this.delete(oldFile.name)
|
||||
logger.info(`[ZimService] Deleted old Wikipedia file: ${oldFile.name}`)
|
||||
} catch (error) {
|
||||
logger.warn(`[ZimService] Could not delete old Wikipedia file: ${oldFile.name}`, error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Download failed - keep the selection record but mark as failed
|
||||
selection.status = 'failed'
|
||||
await selection.save()
|
||||
logger.error(`[ZimService] Wikipedia download failed for: ${selection.filename}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { mkdir, readdir, readFile, stat, unlink } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import path, { join } from 'path'
|
||||
import { FileEntry } from '../../types/files.js'
|
||||
import { createReadStream } from 'fs'
|
||||
import { LSBlockDevice, NomadDiskInfoRaw } from '../../types/system.js'
|
||||
|
|
@ -138,16 +138,39 @@ 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' {
|
||||
const ext = path.extname(filename).toLowerCase()
|
||||
if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp'].includes(ext)) {
|
||||
return 'image'
|
||||
} else if (ext === '.pdf') {
|
||||
return 'pdf'
|
||||
} else if (['.txt', '.md', '.docx', '.rtf'].includes(ext)) {
|
||||
return 'text'
|
||||
} else if (ext === '.zim') {
|
||||
return 'zim'
|
||||
} else {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a filename by removing potentially dangerous characters.
|
||||
* @param filename The original filename
|
||||
* @returns The sanitized filename
|
||||
*/
|
||||
export function sanitizeFilename(filename: string): string {
|
||||
return filename.replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||
}
|
||||
|
|
@ -3,3 +3,23 @@ export function formatSpeed(bytesPerSecond: number): string {
|
|||
if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`
|
||||
return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s`
|
||||
}
|
||||
|
||||
export function toTitleCase(str: string): string {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.split(' ')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
export function parseBoolean(value: any): boolean {
|
||||
if (typeof value === 'boolean') return value
|
||||
if (typeof value === 'string') {
|
||||
const lower = value.toLowerCase()
|
||||
return lower === 'true' || lower === '1'
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value === 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
49
admin/app/utils/version.ts
Normal file
49
admin/app/utils/version.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Compare two semantic version strings to determine if the first is newer than the second.
|
||||
* @param version1 - The version to check (e.g., "1.25.0")
|
||||
* @param version2 - The current version (e.g., "1.24.0")
|
||||
* @returns true if version1 is newer than version2
|
||||
*/
|
||||
export function isNewerVersion(version1: string, version2: string, includePreReleases = false): boolean {
|
||||
const normalize = (v: string) => v.replace(/^v/, '')
|
||||
const [base1, pre1] = normalize(version1).split('-')
|
||||
const [base2, pre2] = normalize(version2).split('-')
|
||||
|
||||
// If pre-releases are not included and version1 is a pre-release, don't consider it newer
|
||||
if (!includePreReleases && pre1) {
|
||||
return false
|
||||
}
|
||||
|
||||
const v1Parts = base1.split('.').map((p) => parseInt(p, 10) || 0)
|
||||
const v2Parts = base2.split('.').map((p) => parseInt(p, 10) || 0)
|
||||
|
||||
const maxLen = Math.max(v1Parts.length, v2Parts.length)
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const a = v1Parts[i] || 0
|
||||
const b = v2Parts[i] || 0
|
||||
if (a > b) return true
|
||||
if (a < b) return false
|
||||
}
|
||||
|
||||
// Base versions equal — GA > RC, RC.n+1 > RC.n
|
||||
if (!pre1 && pre2) return true // v1 is GA, v2 is RC → v1 is newer
|
||||
if (pre1 && !pre2) return false // v1 is RC, v2 is GA → v2 is newer
|
||||
if (!pre1 && !pre2) return false // both GA, equal
|
||||
|
||||
// Both prerelease: compare numeric suffix (e.g. "rc.2" vs "rc.1")
|
||||
const pre1Num = parseInt(pre1.split('.')[1], 10) || 0
|
||||
const pre2Num = parseInt(pre2.split('.')[1], 10) || 0
|
||||
return pre1Num > pre2Num
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the major version number from a tag string.
|
||||
* Strips the 'v' prefix if present.
|
||||
* @param tag - Version tag (e.g., "v3.8.1", "10.19.4")
|
||||
* @returns The major version number
|
||||
*/
|
||||
export function parseMajorVersion(tag: string): number {
|
||||
const normalized = tag.replace(/^v/, '')
|
||||
const major = parseInt(normalized.split('.')[0], 10)
|
||||
return isNaN(major) ? 0 : major
|
||||
}
|
||||
13
admin/app/validators/benchmark.ts
Normal file
13
admin/app/validators/benchmark.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import vine from '@vinejs/vine'
|
||||
|
||||
export const runBenchmarkValidator = vine.compile(
|
||||
vine.object({
|
||||
benchmark_type: vine.enum(['full', 'system', 'ai']).optional(),
|
||||
})
|
||||
)
|
||||
|
||||
export const submitBenchmarkValidator = vine.compile(
|
||||
vine.object({
|
||||
benchmark_id: vine.string().optional(),
|
||||
})
|
||||
)
|
||||
22
admin/app/validators/chat.ts
Normal file
22
admin/app/validators/chat.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import vine from '@vinejs/vine'
|
||||
|
||||
export const createSessionSchema = vine.compile(
|
||||
vine.object({
|
||||
title: vine.string().trim().minLength(1).maxLength(200),
|
||||
model: vine.string().trim().optional(),
|
||||
})
|
||||
)
|
||||
|
||||
export const updateSessionSchema = vine.compile(
|
||||
vine.object({
|
||||
title: vine.string().trim().minLength(1).maxLength(200).optional(),
|
||||
model: vine.string().trim().optional(),
|
||||
})
|
||||
)
|
||||
|
||||
export const addMessageSchema = vine.compile(
|
||||
vine.object({
|
||||
role: vine.enum(['system', 'user', 'assistant'] as const),
|
||||
content: vine.string().trim().minLength(1),
|
||||
})
|
||||
)
|
||||
|
|
@ -1,23 +1,65 @@
|
|||
import vine from '@vinejs/vine'
|
||||
|
||||
/**
|
||||
* Checks whether a URL points to a loopback or link-local address.
|
||||
* Used to prevent SSRF — the server should not fetch from localhost
|
||||
* or link-local/metadata endpoints (e.g. cloud instance metadata at 169.254.x.x).
|
||||
*
|
||||
* RFC1918 private ranges (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.
|
||||
*
|
||||
* Throws an error if the URL is a loopback or link-local address.
|
||||
*/
|
||||
export function assertNotPrivateUrl(urlString: string): void {
|
||||
const parsed = new URL(urlString)
|
||||
const hostname = parsed.hostname.toLowerCase()
|
||||
|
||||
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
|
||||
]
|
||||
|
||||
if (blockedPatterns.some((re) => re.test(hostname))) {
|
||||
throw new Error(`Download URL must not point to a loopback or link-local address: ${hostname}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const remoteDownloadValidator = vine.compile(
|
||||
vine.object({
|
||||
url: vine
|
||||
.string()
|
||||
.url({
|
||||
require_tld: false, // Allow local URLs
|
||||
})
|
||||
.url({ require_tld: false }) // Allow LAN URLs (e.g. http://my-nas:8080/file.zim)
|
||||
.trim(),
|
||||
})
|
||||
)
|
||||
|
||||
export const remoteDownloadWithMetadataValidator = vine.compile(
|
||||
vine.object({
|
||||
url: vine
|
||||
.string()
|
||||
.url({ require_tld: false }) // Allow LAN URLs
|
||||
.trim(),
|
||||
metadata: vine
|
||||
.object({
|
||||
title: vine.string().trim().minLength(1),
|
||||
summary: vine.string().trim().optional(),
|
||||
author: vine.string().trim().optional(),
|
||||
size_bytes: vine.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
)
|
||||
|
||||
export const remoteDownloadValidatorOptional = vine.compile(
|
||||
vine.object({
|
||||
url: vine
|
||||
.string()
|
||||
.url({
|
||||
require_tld: false, // Allow local URLs
|
||||
})
|
||||
.url({ require_tld: false }) // Allow LAN URLs
|
||||
.trim()
|
||||
.optional(),
|
||||
})
|
||||
|
|
@ -36,3 +78,34 @@ export const downloadCollectionValidator = vine.compile(
|
|||
slug: vine.string(),
|
||||
})
|
||||
)
|
||||
|
||||
export const downloadCategoryTierValidator = vine.compile(
|
||||
vine.object({
|
||||
categorySlug: vine.string().trim().minLength(1),
|
||||
tierSlug: vine.string().trim().minLength(1),
|
||||
})
|
||||
)
|
||||
|
||||
export const selectWikipediaValidator = vine.compile(
|
||||
vine.object({
|
||||
optionId: vine.string().trim().minLength(1),
|
||||
})
|
||||
)
|
||||
|
||||
const resourceUpdateInfoBase = vine.object({
|
||||
resource_id: vine.string().trim().minLength(1),
|
||||
resource_type: vine.enum(['zim', 'map'] as const),
|
||||
installed_version: vine.string().trim(),
|
||||
latest_version: vine.string().trim().minLength(1),
|
||||
download_url: vine.string().url({ require_tld: false }).trim(),
|
||||
})
|
||||
|
||||
export const applyContentUpdateValidator = vine.compile(resourceUpdateInfoBase)
|
||||
|
||||
export const applyAllContentUpdatesValidator = vine.compile(
|
||||
vine.object({
|
||||
updates: vine
|
||||
.array(resourceUpdateInfoBase)
|
||||
.minLength(1),
|
||||
})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,21 +1,83 @@
|
|||
import vine from '@vinejs/vine'
|
||||
|
||||
export const curatedCollectionResourceValidator = vine.object({
|
||||
// ---- Versioned resource validators (with id + version) ----
|
||||
|
||||
export const specResourceValidator = vine.object({
|
||||
id: vine.string(),
|
||||
version: vine.string(),
|
||||
title: vine.string(),
|
||||
description: vine.string(),
|
||||
url: vine.string().url(),
|
||||
size_mb: vine.number().min(0).optional(),
|
||||
})
|
||||
|
||||
export const curatedCollectionValidator = vine.object({
|
||||
slug: vine.string(),
|
||||
name: vine.string(),
|
||||
description: vine.string(),
|
||||
icon: vine.string(),
|
||||
language: vine.string().minLength(2).maxLength(5),
|
||||
resources: vine.array(curatedCollectionResourceValidator).minLength(1),
|
||||
// ---- ZIM Categories spec (versioned) ----
|
||||
|
||||
export const zimCategoriesSpecSchema = vine.object({
|
||||
spec_version: vine.string(),
|
||||
categories: vine.array(
|
||||
vine.object({
|
||||
name: vine.string(),
|
||||
slug: vine.string(),
|
||||
icon: vine.string(),
|
||||
description: vine.string(),
|
||||
language: vine.string().minLength(2).maxLength(5),
|
||||
tiers: vine.array(
|
||||
vine.object({
|
||||
name: vine.string(),
|
||||
slug: vine.string(),
|
||||
description: vine.string(),
|
||||
recommended: vine.boolean().optional(),
|
||||
includesTier: vine.string().optional(),
|
||||
resources: vine.array(specResourceValidator),
|
||||
})
|
||||
),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
export const curatedCollectionsFileSchema = vine.object({
|
||||
collections: vine.array(curatedCollectionValidator).minLength(1),
|
||||
// ---- Maps spec (versioned) ----
|
||||
|
||||
export const mapsSpecSchema = vine.object({
|
||||
spec_version: vine.string(),
|
||||
collections: vine.array(
|
||||
vine.object({
|
||||
slug: vine.string(),
|
||||
name: vine.string(),
|
||||
description: vine.string(),
|
||||
icon: vine.string(),
|
||||
language: vine.string().minLength(2).maxLength(5),
|
||||
resources: vine.array(specResourceValidator).minLength(1),
|
||||
})
|
||||
).minLength(1),
|
||||
})
|
||||
|
||||
// ---- Wikipedia spec (versioned) ----
|
||||
|
||||
export const wikipediaSpecSchema = vine.object({
|
||||
spec_version: vine.string(),
|
||||
options: vine.array(
|
||||
vine.object({
|
||||
id: vine.string(),
|
||||
name: vine.string(),
|
||||
description: vine.string(),
|
||||
size_mb: vine.number().min(0),
|
||||
url: vine.string().url().nullable(),
|
||||
version: vine.string().nullable(),
|
||||
})
|
||||
).minLength(1),
|
||||
})
|
||||
|
||||
// ---- Wikipedia validators (used by ZimService) ----
|
||||
|
||||
export const wikipediaOptionSchema = vine.object({
|
||||
id: vine.string(),
|
||||
name: vine.string(),
|
||||
description: vine.string(),
|
||||
size_mb: vine.number().min(0),
|
||||
url: vine.string().url().nullable(),
|
||||
})
|
||||
|
||||
export const wikipediaOptionsFileSchema = vine.object({
|
||||
options: vine.array(wikipediaOptionSchema).minLength(1),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,3 +7,9 @@ export const downloadJobsByFiletypeSchema = vine.compile(
|
|||
}),
|
||||
})
|
||||
)
|
||||
|
||||
export const modelNameSchema = vine.compile(
|
||||
vine.object({
|
||||
model: vine.string(),
|
||||
})
|
||||
)
|
||||
|
|
|
|||
25
admin/app/validators/ollama.ts
Normal file
25
admin/app/validators/ollama.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import vine from '@vinejs/vine'
|
||||
|
||||
export const chatSchema = vine.compile(
|
||||
vine.object({
|
||||
model: vine.string().trim().minLength(1),
|
||||
messages: vine.array(
|
||||
vine.object({
|
||||
role: vine.enum(['system', 'user', 'assistant'] as const),
|
||||
content: vine.string(),
|
||||
})
|
||||
),
|
||||
stream: vine.boolean().optional(),
|
||||
sessionId: vine.number().positive().optional(),
|
||||
})
|
||||
)
|
||||
|
||||
export const getAvailableModelsSchema = vine.compile(
|
||||
vine.object({
|
||||
sort: vine.enum(['pulls', 'name'] as const).optional(),
|
||||
recommendedOnly: vine.boolean().optional(),
|
||||
query: vine.string().trim().optional(),
|
||||
limit: vine.number().positive().optional(),
|
||||
force: vine.boolean().optional(),
|
||||
})
|
||||
)
|
||||
13
admin/app/validators/rag.ts
Normal file
13
admin/app/validators/rag.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import vine from '@vinejs/vine'
|
||||
|
||||
export const getJobStatusSchema = vine.compile(
|
||||
vine.object({
|
||||
filePath: vine.string(),
|
||||
})
|
||||
)
|
||||
|
||||
export const deleteFileSchema = vine.compile(
|
||||
vine.object({
|
||||
source: vine.string(),
|
||||
})
|
||||
)
|
||||
8
admin/app/validators/settings.ts
Normal file
8
admin/app/validators/settings.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import vine from "@vinejs/vine";
|
||||
import { SETTINGS_KEYS } from "../../constants/kv_store.js";
|
||||
|
||||
|
||||
export const updateSettingSchema = vine.compile(vine.object({
|
||||
key: vine.enum(SETTINGS_KEYS),
|
||||
value: vine.any().optional(),
|
||||
}))
|
||||
|
|
@ -1,10 +1,33 @@
|
|||
import vine from '@vinejs/vine'
|
||||
|
||||
export const installServiceValidator = vine.compile(vine.object({
|
||||
service_name: vine.string().trim()
|
||||
}));
|
||||
|
||||
export const affectServiceValidator = vine.compile(vine.object({
|
||||
export const installServiceValidator = vine.compile(
|
||||
vine.object({
|
||||
service_name: vine.string().trim(),
|
||||
action: vine.enum(['start', 'stop', 'restart'])
|
||||
}));
|
||||
})
|
||||
)
|
||||
|
||||
export const affectServiceValidator = vine.compile(
|
||||
vine.object({
|
||||
service_name: vine.string().trim(),
|
||||
action: vine.enum(['start', 'stop', 'restart']),
|
||||
})
|
||||
)
|
||||
|
||||
export const subscribeToReleaseNotesValidator = vine.compile(
|
||||
vine.object({
|
||||
email: vine.string().email().trim(),
|
||||
})
|
||||
)
|
||||
|
||||
export const checkLatestVersionValidator = vine.compile(
|
||||
vine.object({
|
||||
force: vine.boolean().optional(), // Optional flag to force bypassing cache and checking for updates immediately
|
||||
})
|
||||
)
|
||||
|
||||
export const updateServiceValidator = vine.compile(
|
||||
vine.object({
|
||||
service_name: vine.string().trim(),
|
||||
target_version: vine.string().trim(),
|
||||
})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -36,6 +36,15 @@ new Ignitor(APP_ROOT, { importer: IMPORTER })
|
|||
})
|
||||
app.listen('SIGTERM', () => app.terminate())
|
||||
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
|
||||
app.ready(async () => {
|
||||
try {
|
||||
const collectionManifestService = new (await import('#services/collection_manifest_service')).CollectionManifestService()
|
||||
await collectionManifestService.reconcileFromFilesystem()
|
||||
} catch (error) {
|
||||
// Catch and log any errors during reconciliation to prevent the server from crashing
|
||||
console.error('Error during collection manifest reconciliation:', error)
|
||||
}
|
||||
})
|
||||
})
|
||||
.httpServer()
|
||||
.start()
|
||||
|
|
|
|||
98
admin/commands/benchmark/results.ts
Normal file
98
admin/commands/benchmark/results.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { BaseCommand, flags } from '@adonisjs/core/ace'
|
||||
import type { CommandOptions } from '@adonisjs/core/types/ace'
|
||||
|
||||
export default class BenchmarkResults extends BaseCommand {
|
||||
static commandName = 'benchmark:results'
|
||||
static description = 'Display benchmark results'
|
||||
|
||||
@flags.boolean({ description: 'Show only the latest result', alias: 'l' })
|
||||
declare latest: boolean
|
||||
|
||||
@flags.string({ description: 'Output format (table, json)', default: 'table' })
|
||||
declare format: string
|
||||
|
||||
@flags.string({ description: 'Show specific benchmark by ID', alias: 'i' })
|
||||
declare id: string
|
||||
|
||||
static options: CommandOptions = {
|
||||
startApp: true,
|
||||
}
|
||||
|
||||
async run() {
|
||||
const { DockerService } = await import('#services/docker_service')
|
||||
const { BenchmarkService } = await import('#services/benchmark_service')
|
||||
const dockerService = new DockerService()
|
||||
const benchmarkService = new BenchmarkService(dockerService)
|
||||
|
||||
try {
|
||||
let results
|
||||
|
||||
if (this.id) {
|
||||
const result = await benchmarkService.getResultById(this.id)
|
||||
results = result ? [result] : []
|
||||
} else if (this.latest) {
|
||||
const result = await benchmarkService.getLatestResult()
|
||||
results = result ? [result] : []
|
||||
} else {
|
||||
results = await benchmarkService.getAllResults()
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
this.logger.info('No benchmark results found.')
|
||||
this.logger.info('Run "node ace benchmark:run" to create a benchmark.')
|
||||
return
|
||||
}
|
||||
|
||||
if (this.format === 'json') {
|
||||
console.log(JSON.stringify(results, null, 2))
|
||||
return
|
||||
}
|
||||
|
||||
// Table format
|
||||
for (const result of results) {
|
||||
this.logger.info('')
|
||||
this.logger.info(`=== Benchmark ${result.benchmark_id} ===`)
|
||||
this.logger.info(`Type: ${result.benchmark_type}`)
|
||||
this.logger.info(`Date: ${result.created_at}`)
|
||||
this.logger.info('')
|
||||
|
||||
this.logger.info('Hardware:')
|
||||
this.logger.info(` CPU: ${result.cpu_model}`)
|
||||
this.logger.info(` Cores: ${result.cpu_cores} physical, ${result.cpu_threads} threads`)
|
||||
this.logger.info(` RAM: ${Math.round(result.ram_bytes / (1024 * 1024 * 1024))} GB`)
|
||||
this.logger.info(` Disk: ${result.disk_type}`)
|
||||
if (result.gpu_model) {
|
||||
this.logger.info(` GPU: ${result.gpu_model}`)
|
||||
}
|
||||
this.logger.info('')
|
||||
|
||||
this.logger.info('Scores:')
|
||||
this.logger.info(` CPU: ${result.cpu_score.toFixed(2)}`)
|
||||
this.logger.info(` Memory: ${result.memory_score.toFixed(2)}`)
|
||||
this.logger.info(` Disk Read: ${result.disk_read_score.toFixed(2)}`)
|
||||
this.logger.info(` Disk Write: ${result.disk_write_score.toFixed(2)}`)
|
||||
|
||||
if (result.ai_tokens_per_second) {
|
||||
this.logger.info(` AI Tokens/sec: ${result.ai_tokens_per_second.toFixed(2)}`)
|
||||
this.logger.info(` AI TTFT: ${result.ai_time_to_first_token?.toFixed(2)} ms`)
|
||||
}
|
||||
this.logger.info('')
|
||||
|
||||
this.logger.info(`NOMAD Score: ${result.nomad_score.toFixed(2)} / 100`)
|
||||
|
||||
if (result.submitted_to_repository) {
|
||||
this.logger.info(`Submitted: Yes (${result.repository_id})`)
|
||||
} else {
|
||||
this.logger.info('Submitted: No')
|
||||
}
|
||||
this.logger.info('')
|
||||
}
|
||||
|
||||
this.logger.info(`Total results: ${results.length}`)
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to retrieve results: ${error.message}`)
|
||||
this.exitCode = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
105
admin/commands/benchmark/run.ts
Normal file
105
admin/commands/benchmark/run.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { BaseCommand, flags } from '@adonisjs/core/ace'
|
||||
import type { CommandOptions } from '@adonisjs/core/types/ace'
|
||||
|
||||
export default class BenchmarkRun extends BaseCommand {
|
||||
static commandName = 'benchmark:run'
|
||||
static description = 'Run system and/or AI benchmarks to measure server performance'
|
||||
|
||||
@flags.boolean({ description: 'Run system benchmarks only (CPU, memory, disk)', alias: 's' })
|
||||
declare systemOnly: boolean
|
||||
|
||||
@flags.boolean({ description: 'Run AI benchmark only', alias: 'a' })
|
||||
declare aiOnly: boolean
|
||||
|
||||
@flags.boolean({ description: 'Submit results to repository after completion', alias: 'S' })
|
||||
declare submit: boolean
|
||||
|
||||
static options: CommandOptions = {
|
||||
startApp: true,
|
||||
}
|
||||
|
||||
async run() {
|
||||
const { DockerService } = await import('#services/docker_service')
|
||||
const { BenchmarkService } = await import('#services/benchmark_service')
|
||||
const dockerService = new DockerService()
|
||||
const benchmarkService = new BenchmarkService(dockerService)
|
||||
|
||||
// Determine benchmark type
|
||||
let benchmarkType: 'full' | 'system' | 'ai' = 'full'
|
||||
if (this.systemOnly) {
|
||||
benchmarkType = 'system'
|
||||
} else if (this.aiOnly) {
|
||||
benchmarkType = 'ai'
|
||||
}
|
||||
|
||||
this.logger.info(`Starting ${benchmarkType} benchmark...`)
|
||||
this.logger.info('')
|
||||
|
||||
try {
|
||||
// Run the benchmark
|
||||
let result
|
||||
switch (benchmarkType) {
|
||||
case 'system':
|
||||
this.logger.info('Running system benchmarks (CPU, memory, disk)...')
|
||||
result = await benchmarkService.runSystemBenchmarks()
|
||||
break
|
||||
case 'ai':
|
||||
this.logger.info('Running AI benchmark...')
|
||||
result = await benchmarkService.runAIBenchmark()
|
||||
break
|
||||
default:
|
||||
this.logger.info('Running full benchmark suite...')
|
||||
result = await benchmarkService.runFullBenchmark()
|
||||
}
|
||||
|
||||
// Display results
|
||||
this.logger.info('')
|
||||
this.logger.success('Benchmark completed!')
|
||||
this.logger.info('')
|
||||
|
||||
this.logger.info('=== Hardware Info ===')
|
||||
this.logger.info(`CPU: ${result.cpu_model}`)
|
||||
this.logger.info(`Cores: ${result.cpu_cores} physical, ${result.cpu_threads} threads`)
|
||||
this.logger.info(`RAM: ${Math.round(result.ram_bytes / (1024 * 1024 * 1024))} GB`)
|
||||
this.logger.info(`Disk Type: ${result.disk_type}`)
|
||||
if (result.gpu_model) {
|
||||
this.logger.info(`GPU: ${result.gpu_model}`)
|
||||
}
|
||||
|
||||
this.logger.info('')
|
||||
this.logger.info('=== Benchmark Scores ===')
|
||||
this.logger.info(`CPU Score: ${result.cpu_score.toFixed(2)}`)
|
||||
this.logger.info(`Memory Score: ${result.memory_score.toFixed(2)}`)
|
||||
this.logger.info(`Disk Read Score: ${result.disk_read_score.toFixed(2)}`)
|
||||
this.logger.info(`Disk Write Score: ${result.disk_write_score.toFixed(2)}`)
|
||||
|
||||
if (result.ai_tokens_per_second) {
|
||||
this.logger.info(`AI Tokens/sec: ${result.ai_tokens_per_second.toFixed(2)}`)
|
||||
this.logger.info(`AI Time to First Token: ${result.ai_time_to_first_token?.toFixed(2)} ms`)
|
||||
this.logger.info(`AI Model: ${result.ai_model_used}`)
|
||||
}
|
||||
|
||||
this.logger.info('')
|
||||
this.logger.info(`NOMAD Score: ${result.nomad_score.toFixed(2)} / 100`)
|
||||
this.logger.info('')
|
||||
this.logger.info(`Benchmark ID: ${result.benchmark_id}`)
|
||||
|
||||
// Submit if requested
|
||||
if (this.submit) {
|
||||
this.logger.info('')
|
||||
this.logger.info('Submitting results to repository...')
|
||||
try {
|
||||
const submitResult = await benchmarkService.submitToRepository(result.benchmark_id)
|
||||
this.logger.success(`Results submitted! Repository ID: ${submitResult.repository_id}`)
|
||||
this.logger.info(`Your percentile: ${submitResult.percentile}%`)
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to submit: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`Benchmark failed: ${error.message}`)
|
||||
this.exitCode = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
101
admin/commands/benchmark/submit.ts
Normal file
101
admin/commands/benchmark/submit.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { BaseCommand, flags } from '@adonisjs/core/ace'
|
||||
import type { CommandOptions } from '@adonisjs/core/types/ace'
|
||||
|
||||
export default class BenchmarkSubmit extends BaseCommand {
|
||||
static commandName = 'benchmark:submit'
|
||||
static description = 'Submit benchmark results to the community repository'
|
||||
|
||||
@flags.string({ description: 'Benchmark ID to submit (defaults to latest)', alias: 'i' })
|
||||
declare benchmarkId: string
|
||||
|
||||
@flags.boolean({ description: 'Skip confirmation prompt', alias: 'y' })
|
||||
declare yes: boolean
|
||||
|
||||
static options: CommandOptions = {
|
||||
startApp: true,
|
||||
}
|
||||
|
||||
async run() {
|
||||
const { DockerService } = await import('#services/docker_service')
|
||||
const { BenchmarkService } = await import('#services/benchmark_service')
|
||||
const dockerService = new DockerService()
|
||||
const benchmarkService = new BenchmarkService(dockerService)
|
||||
|
||||
try {
|
||||
// Get the result to submit
|
||||
const result = this.benchmarkId
|
||||
? await benchmarkService.getResultById(this.benchmarkId)
|
||||
: await benchmarkService.getLatestResult()
|
||||
|
||||
if (!result) {
|
||||
this.logger.error('No benchmark result found.')
|
||||
this.logger.info('Run "node ace benchmark:run" first to create a benchmark.')
|
||||
this.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
if (result.submitted_to_repository) {
|
||||
this.logger.warning(`Benchmark ${result.benchmark_id} has already been submitted.`)
|
||||
this.logger.info(`Repository ID: ${result.repository_id}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Show what will be submitted
|
||||
this.logger.info('')
|
||||
this.logger.info('=== Data to be submitted ===')
|
||||
this.logger.info('')
|
||||
this.logger.info('Hardware Information:')
|
||||
this.logger.info(` CPU Model: ${result.cpu_model}`)
|
||||
this.logger.info(` CPU Cores: ${result.cpu_cores}`)
|
||||
this.logger.info(` CPU Threads: ${result.cpu_threads}`)
|
||||
this.logger.info(` RAM: ${Math.round(result.ram_bytes / (1024 * 1024 * 1024))} GB`)
|
||||
this.logger.info(` Disk Type: ${result.disk_type}`)
|
||||
if (result.gpu_model) {
|
||||
this.logger.info(` GPU: ${result.gpu_model}`)
|
||||
}
|
||||
this.logger.info('')
|
||||
this.logger.info('Benchmark Scores:')
|
||||
this.logger.info(` CPU Score: ${result.cpu_score.toFixed(2)}`)
|
||||
this.logger.info(` Memory Score: ${result.memory_score.toFixed(2)}`)
|
||||
this.logger.info(` Disk Read: ${result.disk_read_score.toFixed(2)}`)
|
||||
this.logger.info(` Disk Write: ${result.disk_write_score.toFixed(2)}`)
|
||||
if (result.ai_tokens_per_second) {
|
||||
this.logger.info(` AI Tokens/sec: ${result.ai_tokens_per_second.toFixed(2)}`)
|
||||
this.logger.info(` AI TTFT: ${result.ai_time_to_first_token?.toFixed(2)} ms`)
|
||||
}
|
||||
this.logger.info(` NOMAD Score: ${result.nomad_score.toFixed(2)}`)
|
||||
this.logger.info('')
|
||||
this.logger.info('Privacy Notice:')
|
||||
this.logger.info(' - Only the information shown above will be submitted')
|
||||
this.logger.info(' - No IP addresses, hostnames, or personal data is collected')
|
||||
this.logger.info(' - Submissions are completely anonymous')
|
||||
this.logger.info('')
|
||||
|
||||
// Confirm submission
|
||||
if (!this.yes) {
|
||||
const confirm = await this.prompt.confirm(
|
||||
'Do you want to submit this benchmark to the community repository?'
|
||||
)
|
||||
if (!confirm) {
|
||||
this.logger.info('Submission cancelled.')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Submit
|
||||
this.logger.info('Submitting benchmark...')
|
||||
const submitResult = await benchmarkService.submitToRepository(result.benchmark_id)
|
||||
|
||||
this.logger.success('Benchmark submitted successfully!')
|
||||
this.logger.info('')
|
||||
this.logger.info(`Repository ID: ${submitResult.repository_id}`)
|
||||
this.logger.info(`Your percentile: ${submitResult.percentile}%`)
|
||||
this.logger.info('')
|
||||
this.logger.info('Thank you for contributing to the NOMAD community!')
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`Submission failed: ${error.message}`)
|
||||
this.exitCode = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,66 +2,144 @@ import { BaseCommand, flags } from '@adonisjs/core/ace'
|
|||
import type { CommandOptions } from '@adonisjs/core/types/ace'
|
||||
import { Worker } from 'bullmq'
|
||||
import queueConfig from '#config/queue'
|
||||
import { RunDownloadJob } from '#jobs/run_download_job'
|
||||
import { DownloadModelJob } from '#jobs/download_model_job'
|
||||
import { RunBenchmarkJob } from '#jobs/run_benchmark_job'
|
||||
import { EmbedFileJob } from '#jobs/embed_file_job'
|
||||
import { CheckUpdateJob } from '#jobs/check_update_job'
|
||||
import { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job'
|
||||
|
||||
export default class QueueWork extends BaseCommand {
|
||||
static commandName = 'queue:work'
|
||||
static description = 'Start processing jobs from the queue'
|
||||
|
||||
@flags.string({ description: 'Queue name to process', required: true })
|
||||
@flags.string({ description: 'Queue name to process' })
|
||||
declare queue: string
|
||||
|
||||
@flags.boolean({ description: 'Process all queues automatically' })
|
||||
declare all: boolean
|
||||
|
||||
static options: CommandOptions = {
|
||||
startApp: true,
|
||||
staysAlive: true,
|
||||
}
|
||||
|
||||
async run() {
|
||||
const queueName = this.queue || 'default'
|
||||
// Validate that either --queue or --all is provided
|
||||
if (!this.queue && !this.all) {
|
||||
this.logger.error('You must specify either --queue=<name> or --all')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const jobHandlers = await this.loadJobHandlers()
|
||||
if (this.queue && this.all) {
|
||||
this.logger.error('Cannot specify both --queue and --all flags')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const worker = new Worker(
|
||||
queueName,
|
||||
async (job) => {
|
||||
this.logger.info(`Processing job: ${job.id} of type: ${job.name}`)
|
||||
const jobHandler = jobHandlers.get(job.name)
|
||||
if (!jobHandler) {
|
||||
throw new Error(`No handler found for job: ${job.name}`)
|
||||
const [jobHandlers, allQueues] = await this.loadJobHandlers()
|
||||
|
||||
// Determine which queues to process
|
||||
const queuesToProcess = this.all ? Array.from(allQueues.values()) : [this.queue]
|
||||
|
||||
this.logger.info(`Starting workers for queues: ${queuesToProcess.join(', ')}`)
|
||||
|
||||
const workers: Worker[] = []
|
||||
|
||||
// Create a worker for each queue
|
||||
for (const queueName of queuesToProcess) {
|
||||
const worker = new Worker(
|
||||
queueName,
|
||||
async (job) => {
|
||||
this.logger.info(`[${queueName}] Processing job: ${job.id} of type: ${job.name}`)
|
||||
const jobHandler = jobHandlers.get(job.name)
|
||||
if (!jobHandler) {
|
||||
throw new Error(`No handler found for job: ${job.name}`)
|
||||
}
|
||||
|
||||
return await jobHandler.handle(job)
|
||||
},
|
||||
{
|
||||
connection: queueConfig.connection,
|
||||
concurrency: this.getConcurrencyForQueue(queueName),
|
||||
autorun: true,
|
||||
}
|
||||
)
|
||||
|
||||
return await jobHandler.handle(job)
|
||||
},
|
||||
{
|
||||
connection: queueConfig.connection,
|
||||
concurrency: 3,
|
||||
autorun: true,
|
||||
}
|
||||
)
|
||||
worker.on('failed', async (job, err) => {
|
||||
this.logger.error(`[${queueName}] Job failed: ${job?.id}, Error: ${err.message}`)
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
this.logger.error(`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) => {
|
||||
this.logger.info(`Job completed: ${job.id}`)
|
||||
})
|
||||
worker.on('completed', (job) => {
|
||||
this.logger.info(`[${queueName}] Job completed: ${job.id}`)
|
||||
})
|
||||
|
||||
this.logger.info(`Worker started for queue: ${queueName}`)
|
||||
workers.push(worker)
|
||||
this.logger.info(`Worker started for queue: ${queueName}`)
|
||||
}
|
||||
|
||||
// Schedule nightly update checks (idempotent, will persist over restarts)
|
||||
await CheckUpdateJob.scheduleNightly()
|
||||
await CheckServiceUpdatesJob.scheduleNightly()
|
||||
|
||||
// Graceful shutdown for all workers
|
||||
process.on('SIGTERM', async () => {
|
||||
this.logger.info('SIGTERM received. Shutting down worker...')
|
||||
await worker.close()
|
||||
this.logger.info('Worker shut down gracefully.')
|
||||
this.logger.info('SIGTERM received. Shutting down workers...')
|
||||
await Promise.all(workers.map((worker) => worker.close()))
|
||||
this.logger.info('All workers shut down gracefully.')
|
||||
process.exit(0)
|
||||
})
|
||||
}
|
||||
|
||||
private async loadJobHandlers() {
|
||||
private async loadJobHandlers(): Promise<[Map<string, any>, Map<string, string>]> {
|
||||
const handlers = new Map<string, any>()
|
||||
const queues = new Map<string, string>()
|
||||
|
||||
const { RunDownloadJob } = await import('#jobs/run_download_job')
|
||||
handlers.set(RunDownloadJob.key, new RunDownloadJob())
|
||||
handlers.set(DownloadModelJob.key, new DownloadModelJob())
|
||||
handlers.set(RunBenchmarkJob.key, new RunBenchmarkJob())
|
||||
handlers.set(EmbedFileJob.key, new EmbedFileJob())
|
||||
handlers.set(CheckUpdateJob.key, new CheckUpdateJob())
|
||||
handlers.set(CheckServiceUpdatesJob.key, new CheckServiceUpdatesJob())
|
||||
|
||||
return handlers
|
||||
queues.set(RunDownloadJob.key, RunDownloadJob.queue)
|
||||
queues.set(DownloadModelJob.key, DownloadModelJob.queue)
|
||||
queues.set(RunBenchmarkJob.key, RunBenchmarkJob.queue)
|
||||
queues.set(EmbedFileJob.key, EmbedFileJob.queue)
|
||||
queues.set(CheckUpdateJob.key, CheckUpdateJob.queue)
|
||||
queues.set(CheckServiceUpdatesJob.key, CheckServiceUpdatesJob.queue)
|
||||
|
||||
return [handlers, queues]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get concurrency setting for a specific queue
|
||||
* Can be customized per queue based on workload characteristics
|
||||
*/
|
||||
private getConcurrencyForQueue(queueName: string): number {
|
||||
const concurrencyMap: Record<string, number> = {
|
||||
[RunDownloadJob.queue]: 3,
|
||||
[DownloadModelJob.queue]: 2, // Lower concurrency for resource-intensive model downloads
|
||||
[RunBenchmarkJob.queue]: 1, // Run benchmarks one at a time for accurate results
|
||||
[EmbedFileJob.queue]: 2, // Lower concurrency for embedding jobs, can be resource intensive
|
||||
[CheckUpdateJob.queue]: 1, // No need to run more than one update check at a time
|
||||
default: 3,
|
||||
}
|
||||
|
||||
return concurrencyMap[queueName] || concurrencyMap.default
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import KVStore from '#models/kv_store'
|
||||
import { SystemService } from '#services/system_service'
|
||||
import { defineConfig } from '@adonisjs/inertia'
|
||||
import type { InferSharedProps } from '@adonisjs/inertia/types'
|
||||
|
|
@ -14,6 +15,10 @@ const inertiaConfig = defineConfig({
|
|||
sharedData: {
|
||||
appVersion: () => SystemService.getAppVersion(),
|
||||
environment: process.env.NODE_ENV || 'production',
|
||||
aiAssistantName: async () => {
|
||||
const customName = await KVStore.getValue('ai.assistantCustomName')
|
||||
return (customName && customName.trim()) ? customName : 'AI Assistant'
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -13,12 +13,12 @@ const loggerConfig = defineConfig({
|
|||
app: {
|
||||
enabled: true,
|
||||
name: env.get('APP_NAME'),
|
||||
level: env.get('LOG_LEVEL'),
|
||||
level: env.get('NODE_ENV') === 'production' ? env.get('LOG_LEVEL') : 'debug', // default to 'debug' in non-production envs
|
||||
transport: {
|
||||
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(),
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import env from '#start/env'
|
||||
import { defineConfig } from '@adonisjs/transmit'
|
||||
import { redis } from '@adonisjs/transmit/transports'
|
||||
|
||||
export default defineConfig({
|
||||
pingInterval: false,
|
||||
transport: null
|
||||
pingInterval: '30s',
|
||||
transport: {
|
||||
driver: redis({
|
||||
host: env.get('REDIS_HOST'),
|
||||
port: env.get('REDIS_PORT'),
|
||||
keyPrefix: 'transmit:',
|
||||
})
|
||||
}
|
||||
})
|
||||
7
admin/constants/broadcast.ts
Normal file
7
admin/constants/broadcast.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
|
||||
export const BROADCAST_CHANNELS = {
|
||||
BENCHMARK_PROGRESS: 'benchmark-progress',
|
||||
OLLAMA_MODEL_DOWNLOAD: 'ollama-model-download',
|
||||
SERVICE_INSTALLATION: 'service-installation',
|
||||
SERVICE_UPDATES: 'service-updates',
|
||||
}
|
||||
3
admin/constants/kv_store.ts
Normal file
3
admin/constants/kv_store.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { KVStoreKey } from "../types/kv_store.js";
|
||||
|
||||
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'ui.theme', 'system.earlyAccess', 'ai.assistantCustomName'];
|
||||
2
admin/constants/misc.ts
Normal file
2
admin/constants/misc.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
|
||||
export const NOMAD_API_DEFAULT_BASE_URL = 'https://api.projectnomad.us'
|
||||
157
admin/constants/ollama.ts
Normal file
157
admin/constants/ollama.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { NomadOllamaModel } from '../types/ollama.js'
|
||||
|
||||
/**
|
||||
* Fallback basic recommended Ollama models in case fetching from the service fails.
|
||||
*/
|
||||
export const FALLBACK_RECOMMENDED_OLLAMA_MODELS: NomadOllamaModel[] = [
|
||||
{
|
||||
name: 'llama3.1',
|
||||
description:
|
||||
'Llama 3.1 is a new state-of-the-art model from Meta available in 8B, 70B and 405B parameter sizes.',
|
||||
estimated_pulls: '109.3M',
|
||||
id: '9fe9c575-e77e-4a51-a743-07359458ee71',
|
||||
first_seen: '2026-01-28T23:37:31.000+00:00',
|
||||
model_last_updated: '1 year ago',
|
||||
tags: [
|
||||
{
|
||||
name: 'llama3.1:8b-text-q4_1',
|
||||
size: '5.1 GB',
|
||||
context: '128k',
|
||||
input: 'Text',
|
||||
cloud: false,
|
||||
thinking: false
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'deepseek-r1',
|
||||
description:
|
||||
'DeepSeek-R1 is a family of open reasoning models with performance approaching that of leading models, such as O3 and Gemini 2.5 Pro.',
|
||||
estimated_pulls: '77.2M',
|
||||
id: '0b566560-68a6-4964-b0d4-beb3ab1ad694',
|
||||
first_seen: '2026-01-28T23:37:31.000+00:00',
|
||||
model_last_updated: '7 months ago',
|
||||
tags: [
|
||||
{
|
||||
name: 'deepseek-r1:1.5b',
|
||||
size: '1.1 GB',
|
||||
context: '128k',
|
||||
input: 'Text',
|
||||
cloud: false,
|
||||
thinking: true
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'llama3.2',
|
||||
description: "Meta's Llama 3.2 goes small with 1B and 3B models.",
|
||||
estimated_pulls: '54.7M',
|
||||
id: 'c9a1bc23-b290-4501-a913-f7c9bb39c3ad',
|
||||
first_seen: '2026-01-28T23:37:31.000+00:00',
|
||||
model_last_updated: '1 year ago',
|
||||
tags: [
|
||||
{
|
||||
name: 'llama3.2:1b-text-q2_K',
|
||||
size: '581 MB',
|
||||
context: '128k',
|
||||
input: 'Text',
|
||||
cloud: false,
|
||||
thinking: false
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const DEFAULT_QUERY_REWRITE_MODEL = 'qwen2.5:3b' // default to qwen2.5 for query rewriting with good balance of text task performance and resource usage
|
||||
|
||||
/**
|
||||
* Adaptive RAG context limits based on model size.
|
||||
* Smaller models get overwhelmed with too much context, so we cap it.
|
||||
*/
|
||||
export const RAG_CONTEXT_LIMITS: { maxParams: number; maxResults: number; maxTokens: number }[] = [
|
||||
{ maxParams: 3, maxResults: 2, maxTokens: 1000 }, // 1-3B models
|
||||
{ maxParams: 8, maxResults: 4, maxTokens: 2500 }, // 4-8B models
|
||||
{ maxParams: Infinity, maxResults: 5, maxTokens: 0 }, // 13B+ (no cap)
|
||||
]
|
||||
|
||||
export const SYSTEM_PROMPTS = {
|
||||
default: `
|
||||
Format all responses using markdown for better readability. Vanilla markdown or GitHub-flavored markdown is preferred.
|
||||
- Use **bold** and *italic* for emphasis.
|
||||
- Use code blocks with language identifiers for code snippets.
|
||||
- Use headers (##, ###) to organize longer responses.
|
||||
- Use bullet points or numbered lists for clarity.
|
||||
- Use tables when presenting structured data.
|
||||
`,
|
||||
rag_context: (context: string) => `
|
||||
You have access to relevant information from the knowledge base. This context has been retrieved based on semantic similarity to the user's question.
|
||||
|
||||
[Knowledge Base Context]
|
||||
${context}
|
||||
|
||||
IMPORTANT INSTRUCTIONS:
|
||||
1. If the user's question is directly related to the context above, use this information to provide accurate, detailed answers.
|
||||
2. Always cite or reference the context when using it (e.g., "According to the information available..." or "Based on the knowledge base...").
|
||||
3. If the context is only partially relevant, combine it with your general knowledge but be clear about what comes from the knowledge base.
|
||||
4. If the context is not relevant to the user's question, you can respond using your general knowledge without forcing the context into your answer. Do not mention the context if it's not relevant.
|
||||
5. Never fabricate information that isn't in the context or your training data.
|
||||
6. If you're unsure or you don't have enough information to answer the user's question, acknowledge the limitations.
|
||||
|
||||
Format your response using markdown for readability.
|
||||
`,
|
||||
chat_suggestions: `
|
||||
You are a helpful assistant that generates conversation starter suggestions for a survivalist/prepper using an AI assistant.
|
||||
|
||||
Provide exactly 3 conversation starter topics as direct questions that someone would ask.
|
||||
These should be clear, complete questions that can start meaningful conversations.
|
||||
|
||||
Examples of good suggestions:
|
||||
- "How do I purify water in an emergency?"
|
||||
- "What are the best foods for long-term storage?"
|
||||
- "Help me create a 72-hour emergency kit"
|
||||
|
||||
Do NOT use:
|
||||
- Follow-up questions seeking clarification
|
||||
- Vague or incomplete suggestions
|
||||
- Questions that assume prior context
|
||||
- Statements that are not suggestions themselves, such as praise for asking the question
|
||||
- Direct questions or commands to the user
|
||||
|
||||
Return ONLY the 3 suggestions as a comma-separated list with no additional text, formatting, numbering, or quotation marks.
|
||||
The suggestions should be in title case.
|
||||
Ensure that your suggestions are comma-seperated with no conjunctions like "and" or "or".
|
||||
Do not use line breaks, new lines, or extra spacing to separate the suggestions.
|
||||
Format: suggestion1, suggestion2, suggestion3
|
||||
`,
|
||||
title_generation: `You are a title generator. Given the start of a conversation, generate a concise, descriptive title under 50 characters. Return ONLY the title text with no quotes, punctuation wrapping, or extra formatting.`,
|
||||
query_rewrite: `
|
||||
You are a query rewriting assistant. Your task is to reformulate the user's latest question to include relevant context from the conversation history.
|
||||
|
||||
Given the conversation history, rewrite the user's latest question to be a standalone, context-aware search query that will retrieve the most relevant information.
|
||||
|
||||
Rules:
|
||||
1. Keep the rewritten query concise (under 150 words)
|
||||
2. Include key entities, topics, and context from previous messages
|
||||
3. Make it a clear, searchable query
|
||||
4. Do NOT answer the question - only rewrite the user's query to be more effective for retrieval
|
||||
5. Output ONLY the rewritten query, nothing else
|
||||
|
||||
Examples:
|
||||
|
||||
Conversation:
|
||||
User: "How do I install Gentoo?"
|
||||
Assistant: [detailed installation guide]
|
||||
User: "Is an internet connection required to install?"
|
||||
|
||||
Rewritten Query: "Is an internet connection required to install Gentoo Linux?"
|
||||
|
||||
---
|
||||
|
||||
Conversation:
|
||||
User: "What's the best way to preserve meat?"
|
||||
Assistant: [preservation methods]
|
||||
User: "How long does it last?"
|
||||
|
||||
Rewritten Query: "How long does preserved meat last using curing or smoking methods?"
|
||||
`,
|
||||
}
|
||||
8
admin/constants/service_names.ts
Normal file
8
admin/constants/service_names.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export const SERVICE_NAMES = {
|
||||
KIWIX: 'nomad_kiwix_server',
|
||||
OLLAMA: 'nomad_ollama',
|
||||
QDRANT: 'nomad_qdrant',
|
||||
CYBERCHEF: 'nomad_cyberchef',
|
||||
FLATNOTES: 'nomad_flatnotes',
|
||||
KOLIBRI: 'nomad_kolibri',
|
||||
}
|
||||
48
admin/constants/zim_extraction.ts
Normal file
48
admin/constants/zim_extraction.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
|
||||
export const HTML_SELECTORS_TO_REMOVE = [
|
||||
'script',
|
||||
'style',
|
||||
'nav',
|
||||
'header',
|
||||
'footer',
|
||||
'noscript',
|
||||
'iframe',
|
||||
'svg',
|
||||
'.navbox',
|
||||
'.sidebar',
|
||||
'.infobox',
|
||||
'.mw-editsection',
|
||||
'.reference',
|
||||
'.reflist',
|
||||
'.toc',
|
||||
'.noprint',
|
||||
'.mw-jump-link',
|
||||
'.mw-headline-anchor',
|
||||
'[role="navigation"]',
|
||||
'.navbar',
|
||||
'.hatnote',
|
||||
'.ambox',
|
||||
'.sistersitebox',
|
||||
'.portal',
|
||||
'#coordinates',
|
||||
'.geo-nondefault',
|
||||
'.authority-control',
|
||||
]
|
||||
|
||||
// Common heading names that usually don't have meaningful content under them
|
||||
export const NON_CONTENT_HEADING_PATTERNS = [
|
||||
/^see also$/i,
|
||||
/^references$/i,
|
||||
/^external links$/i,
|
||||
/^further reading$/i,
|
||||
/^notes$/i,
|
||||
/^bibliography$/i,
|
||||
/^navigation$/i,
|
||||
]
|
||||
|
||||
/**
|
||||
* Batch size for processing ZIM articles to prevent lock timeout errors.
|
||||
* Processing 50 articles at a time balances throughput with job duration.
|
||||
* Typical processing time: 2-5 minutes per batch depending on article complexity.
|
||||
*/
|
||||
export const ZIM_BATCH_SIZE = 50
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'benchmark_results'
|
||||
|
||||
async up() {
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.increments('id')
|
||||
table.string('benchmark_id').unique().notNullable()
|
||||
table.enum('benchmark_type', ['full', 'system', 'ai']).notNullable()
|
||||
|
||||
// Hardware information
|
||||
table.string('cpu_model').notNullable()
|
||||
table.integer('cpu_cores').notNullable()
|
||||
table.integer('cpu_threads').notNullable()
|
||||
table.bigInteger('ram_bytes').notNullable()
|
||||
table.enum('disk_type', ['ssd', 'hdd', 'nvme', 'unknown']).notNullable()
|
||||
table.string('gpu_model').nullable()
|
||||
|
||||
// System benchmark scores
|
||||
table.float('cpu_score').notNullable()
|
||||
table.float('memory_score').notNullable()
|
||||
table.float('disk_read_score').notNullable()
|
||||
table.float('disk_write_score').notNullable()
|
||||
|
||||
// AI benchmark scores (nullable for system-only benchmarks)
|
||||
table.float('ai_tokens_per_second').nullable()
|
||||
table.string('ai_model_used').nullable()
|
||||
table.float('ai_time_to_first_token').nullable()
|
||||
|
||||
// Composite NOMAD score (0-100)
|
||||
table.float('nomad_score').notNullable()
|
||||
|
||||
// Repository submission tracking
|
||||
table.boolean('submitted_to_repository').defaultTo(false)
|
||||
table.timestamp('submitted_at').nullable()
|
||||
table.string('repository_id').nullable()
|
||||
|
||||
table.timestamp('created_at')
|
||||
table.timestamp('updated_at')
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'benchmark_settings'
|
||||
|
||||
async up() {
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.increments('id')
|
||||
table.string('key').unique().notNullable()
|
||||
table.text('value').nullable()
|
||||
table.timestamp('created_at')
|
||||
table.timestamp('updated_at')
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'services'
|
||||
|
||||
async up() {
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
table.string('powered_by').nullable()
|
||||
table.integer('display_order').nullable().defaultTo(100)
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
table.dropColumn('powered_by')
|
||||
table.dropColumn('display_order')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'services'
|
||||
|
||||
async up() {
|
||||
// Update existing services with new friendly names and powered_by values
|
||||
await this.db.rawQuery(`
|
||||
UPDATE services SET
|
||||
friendly_name = 'Information Library',
|
||||
powered_by = 'Kiwix',
|
||||
display_order = 1,
|
||||
description = 'Offline access to Wikipedia, medical references, how-to guides, and encyclopedias'
|
||||
WHERE service_name = 'nomad_kiwix_serve'
|
||||
`)
|
||||
|
||||
await this.db.rawQuery(`
|
||||
UPDATE services SET
|
||||
friendly_name = 'Education Platform',
|
||||
powered_by = 'Kolibri',
|
||||
display_order = 2,
|
||||
description = 'Interactive learning platform with video courses and exercises'
|
||||
WHERE service_name = 'nomad_kolibri'
|
||||
`)
|
||||
|
||||
await this.db.rawQuery(`
|
||||
UPDATE services SET
|
||||
friendly_name = 'AI Assistant',
|
||||
powered_by = 'Ollama',
|
||||
ui_location = '/chat',
|
||||
display_order = 3,
|
||||
description = 'Local AI chat that runs entirely on your hardware - no internet required'
|
||||
WHERE service_name = 'nomad_ollama'
|
||||
`)
|
||||
|
||||
await this.db.rawQuery(`
|
||||
UPDATE services SET
|
||||
friendly_name = 'Notes',
|
||||
powered_by = 'FlatNotes',
|
||||
display_order = 10,
|
||||
description = 'Simple note-taking app with local storage'
|
||||
WHERE service_name = 'nomad_flatnotes'
|
||||
`)
|
||||
|
||||
await this.db.rawQuery(`
|
||||
UPDATE services SET
|
||||
friendly_name = 'Data Tools',
|
||||
powered_by = 'CyberChef',
|
||||
display_order = 11,
|
||||
description = 'Swiss Army knife for data encoding, encryption, and analysis'
|
||||
WHERE service_name = 'nomad_cyberchef'
|
||||
`)
|
||||
}
|
||||
|
||||
async down() {
|
||||
// Revert to original names
|
||||
await this.db.rawQuery(`
|
||||
UPDATE services SET
|
||||
friendly_name = 'Kiwix',
|
||||
powered_by = NULL,
|
||||
display_order = NULL,
|
||||
description = 'Offline Wikipedia, eBooks, and more'
|
||||
WHERE service_name = 'nomad_kiwix_serve'
|
||||
`)
|
||||
|
||||
await this.db.rawQuery(`
|
||||
UPDATE services SET
|
||||
friendly_name = 'Kolibri',
|
||||
powered_by = NULL,
|
||||
display_order = NULL,
|
||||
description = 'An offline-first education platform for schools and learners'
|
||||
WHERE service_name = 'nomad_kolibri'
|
||||
`)
|
||||
|
||||
await this.db.rawQuery(`
|
||||
UPDATE services SET
|
||||
friendly_name = 'Ollama',
|
||||
powered_by = NULL,
|
||||
display_order = NULL,
|
||||
description = 'Local AI chat that runs entirely on your hardware - no internet required'
|
||||
WHERE service_name = 'nomad_ollama'
|
||||
`)
|
||||
|
||||
await this.db.rawQuery(`
|
||||
UPDATE services SET
|
||||
friendly_name = 'FlatNotes',
|
||||
powered_by = NULL,
|
||||
display_order = NULL,
|
||||
description = 'A simple note-taking app that stores all files locally'
|
||||
WHERE service_name = 'nomad_flatnotes'
|
||||
`)
|
||||
|
||||
await this.db.rawQuery(`
|
||||
UPDATE services SET
|
||||
friendly_name = 'CyberChef',
|
||||
powered_by = NULL,
|
||||
display_order = NULL,
|
||||
description = 'The Cyber Swiss Army Knife - a web app for encryption, encoding, and data analysis'
|
||||
WHERE service_name = 'nomad_cyberchef'
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'benchmark_results'
|
||||
|
||||
async up() {
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
table.string('builder_tag', 64).nullable()
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
table.dropColumn('builder_tag')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'installed_tiers'
|
||||
|
||||
async up() {
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.increments('id').primary()
|
||||
table.string('category_slug').notNullable().unique()
|
||||
table.string('tier_slug').notNullable()
|
||||
table.timestamp('created_at')
|
||||
table.timestamp('updated_at')
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'kv_store'
|
||||
|
||||
async up() {
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.increments('id')
|
||||
table.string('key').unique().notNullable()
|
||||
table.text('value').nullable()
|
||||
table.timestamp('created_at')
|
||||
table.timestamp('updated_at')
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'wikipedia_selections'
|
||||
|
||||
async up() {
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.increments('id').primary()
|
||||
table.string('option_id').notNullable()
|
||||
table.string('url').nullable()
|
||||
table.string('filename').nullable()
|
||||
table.enum('status', ['none', 'downloading', 'installed', 'failed']).defaultTo('none')
|
||||
table.timestamp('created_at')
|
||||
table.timestamp('updated_at')
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'chat_sessions'
|
||||
|
||||
async up() {
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.increments('id')
|
||||
table.string('title').notNullable()
|
||||
table.string('model').nullable()
|
||||
table.timestamp('created_at')
|
||||
table.timestamp('updated_at')
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'chat_messages'
|
||||
|
||||
async up() {
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.increments('id')
|
||||
table.integer('session_id').unsigned().references('id').inTable('chat_sessions').onDelete('CASCADE')
|
||||
table.enum('role', ['system', 'user', 'assistant']).notNullable()
|
||||
table.text('content').notNullable()
|
||||
table.timestamp('created_at')
|
||||
table.timestamp('updated_at')
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user