mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-03-28 03:29:25 +01:00
Compare commits
81 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 |
3
.github/ISSUE_TEMPLATE/config.yml
vendored
3
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -12,3 +12,6 @@ contact_links:
|
||||||
- name: 🤝 Contributing Guide
|
- name: 🤝 Contributing Guide
|
||||||
url: https://github.com/Crosstalk-Solutions/project-nomad/blob/main/CONTRIBUTING.md
|
url: https://github.com/Crosstalk-Solutions/project-nomad/blob/main/CONTRIBUTING.md
|
||||||
about: Learn how to contribute to Project N.O.M.A.D.
|
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
|
||||||
8
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
8
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
|
|
@ -6,13 +6,17 @@ body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Thanks for your interest in improving Project N.O.M.A.D.!
|
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.
|
**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:**
|
**Before submitting:**
|
||||||
- Search existing feature requests to avoid duplicates
|
- 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 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
|
- type: dropdown
|
||||||
id: feature-category
|
id: feature-category
|
||||||
|
|
|
||||||
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,4 +1,4 @@
|
||||||
name: Build Docker Image
|
name: Build Primary Docker Image
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
@ -33,7 +33,7 @@ jobs:
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
- name: Log in to GitHub Container Registry
|
- name: Log in to GitHub Container Registry
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
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' || '' }}
|
||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -22,7 +22,7 @@ jobs:
|
||||||
newVersion: ${{ steps.semver.outputs.new_release_version }}
|
newVersion: ${{ steps.semver.outputs.new_release_version }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
|
||||||
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
|
||||||
|
|
@ -45,7 +45,14 @@ COPY --from=production-deps /app/node_modules /app/node_modules
|
||||||
COPY --from=build /app/build /app
|
COPY --from=build /app/build /app
|
||||||
# Copy root package.json for version info
|
# Copy root package.json for version info
|
||||||
COPY package.json /app/version.json
|
COPY package.json /app/version.json
|
||||||
|
|
||||||
|
# Copy docs and README for access within the container
|
||||||
COPY admin/docs /app/docs
|
COPY admin/docs /app/docs
|
||||||
COPY README.md /app/README.md
|
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
|
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.
|
||||||
53
README.md
53
README.md
|
|
@ -21,13 +21,18 @@ Project N.O.M.A.D. can be installed on any Debian-based operating system (we rec
|
||||||
|
|
||||||
*Note: sudo/root privileges are required to run the install script*
|
*Note: sudo/root privileges are required to run the install script*
|
||||||
|
|
||||||
#### Quick Install
|
### Quick Install (Debian-based OS Only)
|
||||||
```bash
|
```bash
|
||||||
sudo apt-get update && sudo apt-get install -y curl && curl -fsSL https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/install_nomad.sh -o install_nomad.sh && sudo bash install_nomad.sh
|
sudo apt-get update && sudo apt-get install -y curl && curl -fsSL https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/install_nomad.sh -o install_nomad.sh && sudo bash install_nomad.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Project N.O.M.A.D. is now installed on your device! Open a browser and navigate to `http://localhost:8080` (or `http://DEVICE_IP:8080`) to start exploring!
|
Project N.O.M.A.D. is now installed on your device! Open a browser and navigate to `http://localhost:8080` (or `http://DEVICE_IP:8080`) to start exploring!
|
||||||
|
|
||||||
|
For a complete step-by-step walkthrough (including Ubuntu installation), see the [Installation Guide](https://www.projectnomad.us/install).
|
||||||
|
|
||||||
|
### Advanced Installation
|
||||||
|
For more control over the installation process, copy and paste the [Docker Compose template](https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/management_compose.yaml) into a `docker-compose.yml` file and customize it to your liking (be sure to replace any placeholders with your actual values). Then, run `docker compose up -d` to start the Command Center and its dependencies. Note: this method is recommended for advanced users only, as it requires familiarity with Docker and manual configuration before starting.
|
||||||
|
|
||||||
## How It Works
|
## 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.
|
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.
|
||||||
|
|
||||||
|
|
@ -80,10 +85,13 @@ To run LLM's and other included AI tools:
|
||||||
- OS: Debian-based (Ubuntu recommended)
|
- OS: Debian-based (Ubuntu recommended)
|
||||||
- Stable internet connection (required during install only)
|
- Stable internet connection (required during install only)
|
||||||
|
|
||||||
**For detailed build recommendations at three price points ($200–$800+), see the [Hardware Guide](https://www.projectnomad.us/hardware).**
|
**For detailed build recommendations at three price points ($150–$1,000+), see the [Hardware Guide](https://www.projectnomad.us/hardware).**
|
||||||
|
|
||||||
Again, Project N.O.M.A.D. itself is quite lightweight - it's the tools and resources you choose to install with N.O.M.A.D. that will determine the specs required for your unique deployment
|
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
|
## 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.
|
Project N.O.M.A.D. is designed for offline usage. An internet connection is only required during the initial installation (to download dependencies) and if you (the user) decide to download additional tools and resources at a later time. Otherwise, N.O.M.A.D. does not require an internet connection and has ZERO built-in telemetry.
|
||||||
|
|
||||||
|
|
@ -92,49 +100,20 @@ To test internet connectivity, N.O.M.A.D. attempts to make a request to Cloudfla
|
||||||
## About Security
|
## About Security
|
||||||
By design, Project N.O.M.A.D. is intended to be open and available without hurdles - it includes no authentication. If you decide to connect your device to a local network after install (e.g. for allowing other devices to access it's resources), you can block/open ports to control which services are exposed.
|
By design, Project N.O.M.A.D. is intended to be open and available without hurdles - it includes no authentication. If you decide to connect your device to a local network after install (e.g. for allowing other devices to access it's resources), you can block/open ports to control which services are exposed.
|
||||||
|
|
||||||
**Will authentication be added in the future?** Maybe. It's not currently a priority, but if there's enough demand for it, we may consider building in an optional authentication layer in a future release to support uses cases where multiple users need access to the same instance but with different permission levels (e.g. family use with parental controls, classroom use with teacher/admin accounts, etc.). For now, we recommend using network-level controls to manage access if you're planning to expose your N.O.M.A.D. instance to other devices on a local network. N.O.M.A.D. is not designed to be exposed directly to the internet, and we strongly advise against doing so unless you really know what you're doing, have taken appropriate security measures, and understand the risks involved.
|
**Will authentication be added in the future?** Maybe. It's not currently a priority, but if there's enough demand for it, we may consider building in an optional authentication layer in a future release to support uses cases where multiple users need access to the same instance but with different permission levels (e.g. family use with parental controls, classroom use with teacher/admin accounts, etc.). We have a suggestion for this on our public roadmap, so if this is something you'd like to see, please upvote it here: https://roadmap.projectnomad.us/posts/1/user-authentication-please-build-in-user-auth-with-admin-user-roles
|
||||||
|
|
||||||
|
For now, we recommend using network-level controls to manage access if you're planning to expose your N.O.M.A.D. instance to other devices on a local network. N.O.M.A.D. is not designed to be exposed directly to the internet, and we strongly advise against doing so unless you really know what you're doing, have taken appropriate security measures, and understand the risks involved.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
Contributions are welcome and appreciated! Please read this section fully to understand how to contribute to the project.
|
Contributions are welcome and appreciated! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to contribute to the project.
|
||||||
|
|
||||||
### General Guidelines
|
|
||||||
|
|
||||||
- **Open an issue first**: Before starting work on a new feature or bug fix, please open an issue to discuss your proposed changes. This helps ensure that your contribution aligns with the project's goals and avoids duplicate work. Title the issue clearly and provide a detailed description of the problem or feature you want to work on.
|
|
||||||
- **Fork the repository**: Click the "Fork" button at the top right of the repository page to create a copy of the project under your GitHub account.
|
|
||||||
- **Create a new branch**: In your forked repository, create a new branch for your work. Use a descriptive name for the branch that reflects the purpose of your changes (e.g., `fix/issue-123` or `feature/add-new-tool`).
|
|
||||||
- **Make your changes**: Implement your changes in the new branch. Follow the existing code style and conventions used in the project. Be sure to test your changes locally to ensure they work as expected.
|
|
||||||
- **Add Release Notes**: If your changes include new features, bug fixes, or improvements, please see the "Release Notes" section below to properly document your contribution for the next release.
|
|
||||||
- **Conventional Commits**: When committing your changes, please use conventional commit messages to provide clear and consistent commit history. The format is `<type>(<scope>): <description>`, where:
|
|
||||||
- `type` is the type of change (e.g., `feat` for new features, `fix` for bug fixes, `docs` for documentation changes, etc.)
|
|
||||||
- `scope` is an optional area of the codebase that your change affects (e.g., `api`, `ui`, `docs`, etc.)
|
|
||||||
- `description` is a brief summary of the change
|
|
||||||
- **Submit a pull request**: Once your changes are ready, submit a pull request to the main repository. Provide a clear description of your changes and reference any related issues. The project maintainers will review your pull request and may provide feedback or request changes before it can be merged.
|
|
||||||
- **Be responsive to feedback**: If the maintainers request changes or provide feedback on your pull request, please respond in a timely manner. Stale pull requests may be closed if there is no activity for an extended period.
|
|
||||||
- **Follow the project's code of conduct**: Please adhere to the project's code of conduct when interacting with maintainers and other contributors. Be respectful and considerate in your communications.
|
|
||||||
- **No guarantee of acceptance**: The project is community-driven, and all contributions are appreciated, but acceptance is not guaranteed. The maintainers will evaluate each contribution based on its quality, relevance, and alignment with the project's goals.
|
|
||||||
- **Thank you for contributing to Project N.O.M.A.D.!** Your efforts help make this project better for everyone.
|
|
||||||
|
|
||||||
### Versioning
|
|
||||||
This project uses semantic versioning. The version is managed in the root `package.json`
|
|
||||||
and automatically updated by semantic-release. For simplicity's sake, the "project-nomad" image
|
|
||||||
uses the same version defined there instead of the version in `admin/package.json` (stays at 0.0.0), as it's the only published image derived from the code.
|
|
||||||
|
|
||||||
### Release Notes
|
|
||||||
Human-readable release notes live in [`admin/docs/release-notes.md`](admin/docs/release-notes.md) and are displayed in the Command Center's built-in documentation.
|
|
||||||
|
|
||||||
When working on changes, add a summary to the `## Unreleased` section at the top of that file under the appropriate heading:
|
|
||||||
|
|
||||||
- **Features** — new user-facing capabilities
|
|
||||||
- **Bug Fixes** — corrections to existing behavior
|
|
||||||
- **Improvements** — enhancements, refactors, docs, or dependency updates
|
|
||||||
|
|
||||||
Use the format `- **Area**: Description` to stay consistent with existing entries. When a release is triggered, CI automatically stamps the version and date, commits the update, and pushes the content to the GitHub release.
|
|
||||||
|
|
||||||
## Community & Resources
|
## Community & Resources
|
||||||
|
|
||||||
- **Website:** [www.projectnomad.us](https://www.projectnomad.us) - Learn more about the project
|
- **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
|
- **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
|
- **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
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,9 @@ export default class DownloadsController {
|
||||||
const payload = await request.validateUsing(downloadJobsByFiletypeSchema)
|
const payload = await request.validateUsing(downloadJobsByFiletypeSchema)
|
||||||
return this.downloadService.listDownloadJobs(payload.params.filetype)
|
return this.downloadService.listDownloadJobs(payload.params.filetype)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async removeJob({ params }: HttpContext) {
|
||||||
|
await this.downloadService.removeFailedJob(params.jobId)
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ export default class MapsController {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = await this.mapService.generateStylesJSON(request.host())
|
const styles = await this.mapService.generateStylesJSON(request.host(), request.protocol())
|
||||||
return response.json(styles)
|
return response.json(styles)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,10 @@ export default class SettingsController {
|
||||||
return inertia.render('settings/legal');
|
return inertia.render('settings/legal');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async support({ inertia }: HttpContext) {
|
||||||
|
return inertia.render('settings/support');
|
||||||
|
}
|
||||||
|
|
||||||
async maps({ inertia }: HttpContext) {
|
async maps({ inertia }: HttpContext) {
|
||||||
const baseAssetsCheck = await this.mapService.ensureBaseAssets();
|
const baseAssetsCheck = await this.mapService.ensureBaseAssets();
|
||||||
const regionFiles = await this.mapService.listRegions();
|
const regionFiles = await this.mapService.listRegions();
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ export default class SystemController {
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
response.send({ success: true, message: result.message });
|
response.send({ success: true, message: result.message });
|
||||||
} else {
|
} else {
|
||||||
response.status(400).send({ error: result.message });
|
response.status(400).send({ success: false, message: result.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,6 +113,11 @@ export default class SystemController {
|
||||||
return await this.systemService.subscribeToReleaseNotes(reqData.email);
|
return await this.systemService.subscribeToReleaseNotes(reqData.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getDebugInfo({}: HttpContext) {
|
||||||
|
const debugInfo = await this.systemService.getDebugInfo()
|
||||||
|
return { debugInfo }
|
||||||
|
}
|
||||||
|
|
||||||
async checkServiceUpdates({ response }: HttpContext) {
|
async checkServiceUpdates({ response }: HttpContext) {
|
||||||
await CheckServiceUpdatesJob.dispatch()
|
await CheckServiceUpdatesJob.dispatch()
|
||||||
response.send({ success: true, message: 'Service update check dispatched' })
|
response.send({ success: true, message: 'Service update check dispatched' })
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Job } from 'bullmq'
|
import { Job, UnrecoverableError } from 'bullmq'
|
||||||
import { QueueService } from '#services/queue_service'
|
import { QueueService } from '#services/queue_service'
|
||||||
import { createHash } from 'crypto'
|
import { createHash } from 'crypto'
|
||||||
import logger from '@adonisjs/core/services/logger'
|
import logger from '@adonisjs/core/services/logger'
|
||||||
|
|
@ -63,6 +63,10 @@ export class DownloadModelJob {
|
||||||
logger.error(
|
logger.error(
|
||||||
`[DownloadModelJob] Failed to initiate download for model ${modelName}: ${result.message}`
|
`[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}`)
|
throw new Error(`Failed to initiate download for model: ${result.message}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,6 +89,15 @@ export class DownloadModelJob {
|
||||||
const queue = queueService.getQueue(this.queue)
|
const queue = queueService.getQueue(this.queue)
|
||||||
const jobId = this.getJobId(params.modelName)
|
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 {
|
try {
|
||||||
const job = await queue.add(this.key, params, {
|
const job = await queue.add(this.key, params, {
|
||||||
jobId,
|
jobId,
|
||||||
|
|
@ -104,9 +117,9 @@ export class DownloadModelJob {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.message.includes('job already exists')) {
|
if (error.message.includes('job already exists')) {
|
||||||
const existing = await queue.getJob(jobId)
|
const active = await queue.getJob(jobId)
|
||||||
return {
|
return {
|
||||||
job: existing,
|
job: active,
|
||||||
created: false,
|
created: false,
|
||||||
message: `Job already exists for model ${params.modelName}`,
|
message: `Job already exists for model ${params.modelName}`,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Job } from 'bullmq'
|
import { Job, UnrecoverableError } from 'bullmq'
|
||||||
import { QueueService } from '#services/queue_service'
|
import { QueueService } from '#services/queue_service'
|
||||||
import { EmbedJobWithProgress } from '../../types/rag.js'
|
import { EmbedJobWithProgress } from '../../types/rag.js'
|
||||||
import { RagService } from '#services/rag_service'
|
import { RagService } from '#services/rag_service'
|
||||||
|
|
@ -42,7 +42,15 @@ export class EmbedFileJob {
|
||||||
const ragService = new RagService(dockerService, ollamaService)
|
const ragService = new RagService(dockerService, ollamaService)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if Ollama and Qdrant services are ready
|
// Check if Ollama and Qdrant services are installed and ready
|
||||||
|
// Use UnrecoverableError for "not installed" so BullMQ won't retry —
|
||||||
|
// retrying 30x when the service doesn't exist just wastes Redis connections
|
||||||
|
const ollamaUrl = await dockerService.getServiceURL('nomad_ollama')
|
||||||
|
if (!ollamaUrl) {
|
||||||
|
logger.warn('[EmbedFileJob] Ollama is not installed. Skipping embedding for: %s', fileName)
|
||||||
|
throw new UnrecoverableError('Ollama service is not installed. Install AI Assistant to enable file embeddings.')
|
||||||
|
}
|
||||||
|
|
||||||
const existingModels = await ollamaService.getModels()
|
const existingModels = await ollamaService.getModels()
|
||||||
if (!existingModels) {
|
if (!existingModels) {
|
||||||
logger.warn('[EmbedFileJob] Ollama service not ready yet. Will retry...')
|
logger.warn('[EmbedFileJob] Ollama service not ready yet. Will retry...')
|
||||||
|
|
@ -51,8 +59,8 @@ export class EmbedFileJob {
|
||||||
|
|
||||||
const qdrantUrl = await dockerService.getServiceURL('nomad_qdrant')
|
const qdrantUrl = await dockerService.getServiceURL('nomad_qdrant')
|
||||||
if (!qdrantUrl) {
|
if (!qdrantUrl) {
|
||||||
logger.warn('[EmbedFileJob] Qdrant service not ready yet. Will retry...')
|
logger.warn('[EmbedFileJob] Qdrant is not installed. Skipping embedding for: %s', fileName)
|
||||||
throw new Error('Qdrant service not ready yet')
|
throw new UnrecoverableError('Qdrant service is not installed. Install AI Assistant to enable file embeddings.')
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`[EmbedFileJob] Services ready. Processing file: ${fileName}`)
|
logger.info(`[EmbedFileJob] Services ready. Processing file: ${fileName}`)
|
||||||
|
|
|
||||||
|
|
@ -82,14 +82,17 @@ export class RunDownloadJob {
|
||||||
const zimService = new ZimService(dockerService)
|
const zimService = new ZimService(dockerService)
|
||||||
await zimService.downloadRemoteSuccessCallback([url], true)
|
await zimService.downloadRemoteSuccessCallback([url], true)
|
||||||
|
|
||||||
// Dispatch an embedding job for the downloaded ZIM file
|
// Only dispatch embedding job if AI Assistant (Ollama) is installed
|
||||||
try {
|
const ollamaUrl = await dockerService.getServiceURL('nomad_ollama')
|
||||||
await EmbedFileJob.dispatch({
|
if (ollamaUrl) {
|
||||||
fileName: url.split('/').pop() || '',
|
try {
|
||||||
filePath: filepath,
|
await EmbedFileJob.dispatch({
|
||||||
})
|
fileName: url.split('/').pop() || '',
|
||||||
} catch (error) {
|
filePath: filepath,
|
||||||
console.error(`[RunDownloadJob] Error dispatching EmbedFileJob for URL ${url}:`, error)
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[RunDownloadJob] Error dispatching EmbedFileJob for URL ${url}:`, error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (filetype === 'map') {
|
} else if (filetype === 'map') {
|
||||||
const mapsService = new MapService()
|
const mapsService = new MapService()
|
||||||
|
|
|
||||||
|
|
@ -571,10 +571,10 @@ export class BenchmarkService {
|
||||||
*/
|
*/
|
||||||
private _normalizeScore(value: number, reference: number): number {
|
private _normalizeScore(value: number, reference: number): number {
|
||||||
if (value <= 0) return 0
|
if (value <= 0) return 0
|
||||||
// Log scale: score = 50 * (1 + log2(value/reference))
|
// Log scale with widened range: dividing log2 by 3 prevents scores from
|
||||||
// This gives 50 at reference value, scales logarithmically
|
// clamping to 0% for below-average hardware. Gives 50% at reference value.
|
||||||
const ratio = value / reference
|
const ratio = value / reference
|
||||||
const score = 50 * (1 + Math.log2(Math.max(0.01, ratio)))
|
const score = 50 * (1 + Math.log2(Math.max(0.01, ratio)) / 3)
|
||||||
return Math.min(100, Math.max(0, score)) / 100
|
return Math.min(100, Math.max(0, score)) / 100
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -583,9 +583,9 @@ export class BenchmarkService {
|
||||||
*/
|
*/
|
||||||
private _normalizeScoreInverse(value: number, reference: number): number {
|
private _normalizeScoreInverse(value: number, reference: number): number {
|
||||||
if (value <= 0) return 1
|
if (value <= 0) return 1
|
||||||
// Inverse: lower values = higher scores
|
// Inverse: lower values = higher scores, with widened log range
|
||||||
const ratio = reference / value
|
const ratio = reference / value
|
||||||
const score = 50 * (1 + Math.log2(Math.max(0.01, ratio)))
|
const score = 50 * (1 + Math.log2(Math.max(0.01, ratio)) / 3)
|
||||||
return Math.min(100, Math.max(0, score)) / 100
|
return Math.min(100, Math.max(0, score)) / 100
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -619,6 +619,7 @@ export class BenchmarkService {
|
||||||
const eventsMatch = output.match(/events per second:\s*([\d.]+)/i)
|
const eventsMatch = output.match(/events per second:\s*([\d.]+)/i)
|
||||||
const totalTimeMatch = output.match(/total time:\s*([\d.]+)s/i)
|
const totalTimeMatch = output.match(/total time:\s*([\d.]+)s/i)
|
||||||
const totalEventsMatch = output.match(/total number of events:\s*(\d+)/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 {
|
return {
|
||||||
events_per_second: eventsMatch ? parseFloat(eventsMatch[1]) : 0,
|
events_per_second: eventsMatch ? parseFloat(eventsMatch[1]) : 0,
|
||||||
|
|
|
||||||
|
|
@ -615,8 +615,8 @@ export class DockerService {
|
||||||
* We'll download the lightweight mini Wikipedia Top 100 zim file for this purpose.
|
* We'll download the lightweight mini Wikipedia Top 100 zim file for this purpose.
|
||||||
**/
|
**/
|
||||||
const WIKIPEDIA_ZIM_URL =
|
const WIKIPEDIA_ZIM_URL =
|
||||||
'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/main/install/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_2025-06.zim'
|
const filename = 'wikipedia_en_100_mini_2026-01.zim'
|
||||||
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
|
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
|
||||||
logger.info(`[DockerService] Kiwix Serve pre-install: Downloading ZIM file to ${filepath}`)
|
logger.info(`[DockerService] Kiwix Serve pre-install: Downloading ZIM file to ${filepath}`)
|
||||||
|
|
||||||
|
|
@ -691,6 +691,7 @@ export class DockerService {
|
||||||
const runtimes = dockerInfo.Runtimes || {}
|
const runtimes = dockerInfo.Runtimes || {}
|
||||||
if ('nvidia' in runtimes) {
|
if ('nvidia' in runtimes) {
|
||||||
logger.info('[DockerService] NVIDIA container runtime detected via Docker API')
|
logger.info('[DockerService] NVIDIA container runtime detected via Docker API')
|
||||||
|
await this._persistGPUType('nvidia')
|
||||||
return { type: 'nvidia' }
|
return { type: 'nvidia' }
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -722,12 +723,26 @@ export class DockerService {
|
||||||
)
|
)
|
||||||
if (amdCheck.trim()) {
|
if (amdCheck.trim()) {
|
||||||
logger.info('[DockerService] AMD GPU detected via lspci')
|
logger.info('[DockerService] AMD GPU detected via lspci')
|
||||||
|
await this._persistGPUType('amd')
|
||||||
return { type: 'amd' }
|
return { type: 'amd' }
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// lspci not available, continue
|
// 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')
|
logger.info('[DockerService] No GPU detected')
|
||||||
return { type: 'none' }
|
return { type: 'none' }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -736,6 +751,15 @@ export class DockerService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async _persistGPUType(type: 'nvidia' | 'amd'): Promise<void> {
|
||||||
|
try {
|
||||||
|
await KVStore.setValue('gpu.type', type)
|
||||||
|
logger.info(`[DockerService] Persisted GPU type '${type}' to KV store`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`[DockerService] Failed to persist GPU type: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Discover AMD GPU DRI devices dynamically.
|
* Discover AMD GPU DRI devices dynamically.
|
||||||
* Returns an array of device configurations for Docker.
|
* Returns an array of device configurations for Docker.
|
||||||
|
|
@ -853,6 +877,45 @@ export class DockerService {
|
||||||
this._broadcast(serviceName, 'update-creating', `Creating updated container...`)
|
this._broadcast(serviceName, 'update-creating', `Creating updated container...`)
|
||||||
|
|
||||||
const hostConfig = inspectData.HostConfig || {}
|
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 = {
|
const newContainerConfig: any = {
|
||||||
Image: newImage,
|
Image: newImage,
|
||||||
name: serviceName,
|
name: serviceName,
|
||||||
|
|
@ -865,7 +928,7 @@ export class DockerService {
|
||||||
Binds: hostConfig.Binds || undefined,
|
Binds: hostConfig.Binds || undefined,
|
||||||
PortBindings: hostConfig.PortBindings || undefined,
|
PortBindings: hostConfig.PortBindings || undefined,
|
||||||
RestartPolicy: hostConfig.RestartPolicy || undefined,
|
RestartPolicy: hostConfig.RestartPolicy || undefined,
|
||||||
DeviceRequests: hostConfig.DeviceRequests || undefined,
|
DeviceRequests: serviceName === SERVICE_NAMES.OLLAMA ? updatedDeviceRequests : (hostConfig.DeviceRequests || undefined),
|
||||||
Devices: hostConfig.Devices || undefined,
|
Devices: hostConfig.Devices || undefined,
|
||||||
},
|
},
|
||||||
NetworkingConfig: inspectData.NetworkSettings?.Networks
|
NetworkingConfig: inspectData.NetworkSettings?.Networks
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export class DownloadService {
|
||||||
async listDownloadJobs(filetype?: string): Promise<DownloadJobWithProgress[]> {
|
async listDownloadJobs(filetype?: string): Promise<DownloadJobWithProgress[]> {
|
||||||
// Get regular file download jobs (zim, map, etc.)
|
// Get regular file download jobs (zim, map, etc.)
|
||||||
const queue = this.queueService.getQueue(RunDownloadJob.queue)
|
const queue = this.queueService.getQueue(RunDownloadJob.queue)
|
||||||
const fileJobs = await queue.getJobs(['waiting', 'active', 'delayed'])
|
const fileJobs = await queue.getJobs(['waiting', 'active', 'delayed', 'failed'])
|
||||||
|
|
||||||
const fileDownloads = fileJobs.map((job) => ({
|
const fileDownloads = fileJobs.map((job) => ({
|
||||||
jobId: job.id!.toString(),
|
jobId: job.id!.toString(),
|
||||||
|
|
@ -20,11 +20,13 @@ export class DownloadService {
|
||||||
progress: parseInt(job.progress.toString(), 10),
|
progress: parseInt(job.progress.toString(), 10),
|
||||||
filepath: normalize(job.data.filepath),
|
filepath: normalize(job.data.filepath),
|
||||||
filetype: job.data.filetype,
|
filetype: job.data.filetype,
|
||||||
|
status: (job.failedReason ? 'failed' : 'active') as 'active' | 'failed',
|
||||||
|
failedReason: job.failedReason || undefined,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Get Ollama model download jobs
|
// Get Ollama model download jobs
|
||||||
const modelQueue = this.queueService.getQueue(DownloadModelJob.queue)
|
const modelQueue = this.queueService.getQueue(DownloadModelJob.queue)
|
||||||
const modelJobs = await modelQueue.getJobs(['waiting', 'active', 'delayed'])
|
const modelJobs = await modelQueue.getJobs(['waiting', 'active', 'delayed', 'failed'])
|
||||||
|
|
||||||
const modelDownloads = modelJobs.map((job) => ({
|
const modelDownloads = modelJobs.map((job) => ({
|
||||||
jobId: job.id!.toString(),
|
jobId: job.id!.toString(),
|
||||||
|
|
@ -32,6 +34,8 @@ export class DownloadService {
|
||||||
progress: parseInt(job.progress.toString(), 10),
|
progress: parseInt(job.progress.toString(), 10),
|
||||||
filepath: job.data.modelName || 'Unknown Model', // Use model name as filepath
|
filepath: job.data.modelName || 'Unknown Model', // Use model name as filepath
|
||||||
filetype: 'model',
|
filetype: 'model',
|
||||||
|
status: (job.failedReason ? 'failed' : 'active') as 'active' | 'failed',
|
||||||
|
failedReason: job.failedReason || undefined,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const allDownloads = [...fileDownloads, ...modelDownloads]
|
const allDownloads = [...fileDownloads, ...modelDownloads]
|
||||||
|
|
@ -39,7 +43,22 @@ export class DownloadService {
|
||||||
// Filter by filetype if specified
|
// Filter by filetype if specified
|
||||||
const filtered = allDownloads.filter((job) => !filetype || job.filetype === filetype)
|
const filtered = allDownloads.filter((job) => !filetype || job.filetype === filetype)
|
||||||
|
|
||||||
// Sort so actively downloading items (progress > 0) appear first, then by progress descending
|
// Sort: active downloads first (by progress desc), then failed at the bottom
|
||||||
return filtered.sort((a, b) => b.progress - a.progress)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -260,7 +260,7 @@ export class MapService implements IMapService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateStylesJSON(host: string | null = null): Promise<BaseStylesFile> {
|
async generateStylesJSON(host: string | null = null, protocol: string = 'http'): Promise<BaseStylesFile> {
|
||||||
if (!(await this.checkBaseAssetsExist())) {
|
if (!(await this.checkBaseAssetsExist())) {
|
||||||
throw new Error('Base map assets are missing from storage/maps')
|
throw new Error('Base map assets are missing from storage/maps')
|
||||||
}
|
}
|
||||||
|
|
@ -281,8 +281,8 @@ export class MapService implements IMapService {
|
||||||
* e.g. user is accessing from "example.com", but we would by default generate "localhost:8080/..." so maps would
|
* e.g. user is accessing from "example.com", but we would by default generate "localhost:8080/..." so maps would
|
||||||
* fail to load.
|
* fail to load.
|
||||||
*/
|
*/
|
||||||
const sources = this.generateSourcesArray(host, regions)
|
const sources = this.generateSourcesArray(host, regions, protocol)
|
||||||
const baseUrl = this.getPublicFileBaseUrl(host, this.basemapsAssetsDir)
|
const baseUrl = this.getPublicFileBaseUrl(host, this.basemapsAssetsDir, protocol)
|
||||||
|
|
||||||
const styles = await this.generateStylesFile(
|
const styles = await this.generateStylesFile(
|
||||||
rawStyles,
|
rawStyles,
|
||||||
|
|
@ -342,9 +342,9 @@ export class MapService implements IMapService {
|
||||||
return await listDirectoryContentsRecursive(this.baseDirPath)
|
return await listDirectoryContentsRecursive(this.baseDirPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateSourcesArray(host: string | null, regions: FileEntry[]): BaseStylesFile['sources'][] {
|
private generateSourcesArray(host: string | null, regions: FileEntry[], protocol: string = 'http'): BaseStylesFile['sources'][] {
|
||||||
const sources: BaseStylesFile['sources'][] = []
|
const sources: BaseStylesFile['sources'][] = []
|
||||||
const baseUrl = this.getPublicFileBaseUrl(host, 'pmtiles')
|
const baseUrl = this.getPublicFileBaseUrl(host, 'pmtiles', protocol)
|
||||||
|
|
||||||
for (const region of regions) {
|
for (const region of regions) {
|
||||||
if (region.type === 'file' && region.name.endsWith('.pmtiles')) {
|
if (region.type === 'file' && region.name.endsWith('.pmtiles')) {
|
||||||
|
|
@ -433,7 +433,7 @@ export class MapService implements IMapService {
|
||||||
/*
|
/*
|
||||||
* Gets the appropriate public URL for a map asset depending on environment
|
* Gets the appropriate public URL for a map asset depending on environment
|
||||||
*/
|
*/
|
||||||
private getPublicFileBaseUrl(specifiedHost: string | null, childPath: string): string {
|
private getPublicFileBaseUrl(specifiedHost: string | null, childPath: string, protocol: string = 'http'): string {
|
||||||
function getHost() {
|
function getHost() {
|
||||||
try {
|
try {
|
||||||
const localUrlRaw = env.get('URL')
|
const localUrlRaw = env.get('URL')
|
||||||
|
|
@ -447,7 +447,7 @@ export class MapService implements IMapService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = specifiedHost || getHost()
|
const host = specifiedHost || getHost()
|
||||||
const withProtocol = host.startsWith('http') ? host : `http://${host}`
|
const withProtocol = host.startsWith('http') ? host : `${protocol}://${host}`
|
||||||
const baseUrlPath =
|
const baseUrlPath =
|
||||||
process.env.NODE_ENV === 'production' ? childPath : urlJoin(this.mapStoragePath, childPath)
|
process.env.NODE_ENV === 'production' ? childPath : urlJoin(this.mapStoragePath, childPath)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ export class OllamaService {
|
||||||
* @param model Model name to download
|
* @param model Model name to download
|
||||||
* @returns Success status and message
|
* @returns Success status and message
|
||||||
*/
|
*/
|
||||||
async downloadModel(model: string, progressCallback?: (percent: number) => void): Promise<{ success: boolean; message: string }> {
|
async downloadModel(model: string, progressCallback?: (percent: number) => void): Promise<{ success: boolean; message: string; retryable?: boolean }> {
|
||||||
try {
|
try {
|
||||||
await this._ensureDependencies()
|
await this._ensureDependencies()
|
||||||
if (!this.ollama) {
|
if (!this.ollama) {
|
||||||
|
|
@ -86,11 +86,21 @@ export class OllamaService {
|
||||||
logger.info(`[OllamaService] Model "${model}" downloaded successfully.`)
|
logger.info(`[OllamaService] Model "${model}" downloaded successfully.`)
|
||||||
return { success: true, message: 'Model downloaded successfully.' }
|
return { success: true, message: 'Model downloaded successfully.' }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
logger.error(
|
logger.error(
|
||||||
`[OllamaService] Failed to download model "${model}": ${error instanceof Error ? error.message : error
|
`[OllamaService] Failed to download model "${model}": ${errorMessage}`
|
||||||
}`
|
|
||||||
)
|
)
|
||||||
return { success: false, message: 'Failed to download model.' }
|
|
||||||
|
// Check for version mismatch (Ollama 412 response)
|
||||||
|
const isVersionMismatch = errorMessage.includes('newer version of Ollama')
|
||||||
|
const userMessage = isVersionMismatch
|
||||||
|
? 'This model requires a newer version of Ollama. Please update AI Assistant from the Apps page.'
|
||||||
|
: `Failed to download model: ${errorMessage}`
|
||||||
|
|
||||||
|
// Broadcast failure to connected clients so UI can show the error
|
||||||
|
this.broadcastDownloadError(model, userMessage)
|
||||||
|
|
||||||
|
return { success: false, message: userMessage, retryable: !isVersionMismatch }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -379,6 +389,15 @@ export class OllamaService {
|
||||||
return models
|
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) {
|
private broadcastDownloadProgress(model: string, percent: number) {
|
||||||
transmit.broadcast(BROADCAST_CHANNELS.OLLAMA_MODEL_DOWNLOAD, {
|
transmit.broadcast(BROADCAST_CHANNELS.OLLAMA_MODEL_DOWNLOAD, {
|
||||||
model,
|
model,
|
||||||
|
|
|
||||||
|
|
@ -410,6 +410,117 @@ export class SystemService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getDebugInfo(): Promise<string> {
|
||||||
|
const appVersion = SystemService.getAppVersion()
|
||||||
|
const environment = process.env.NODE_ENV || 'unknown'
|
||||||
|
|
||||||
|
const [systemInfo, services, internetStatus, versionCheck] = await Promise.all([
|
||||||
|
this.getSystemInfo(),
|
||||||
|
this.getServices({ installedOnly: false }),
|
||||||
|
this.getInternetStatus().catch(() => null),
|
||||||
|
this.checkLatestVersion().catch(() => null),
|
||||||
|
])
|
||||||
|
|
||||||
|
const lines: string[] = [
|
||||||
|
'Project NOMAD Debug Info',
|
||||||
|
'========================',
|
||||||
|
`App Version: ${appVersion}`,
|
||||||
|
`Environment: ${environment}`,
|
||||||
|
]
|
||||||
|
|
||||||
|
if (systemInfo) {
|
||||||
|
const { cpu, mem, os, disk, fsSize, uptime, graphics } = systemInfo
|
||||||
|
|
||||||
|
lines.push('')
|
||||||
|
lines.push('System:')
|
||||||
|
if (os.distro) lines.push(` OS: ${os.distro}`)
|
||||||
|
if (os.hostname) lines.push(` Hostname: ${os.hostname}`)
|
||||||
|
if (os.kernel) lines.push(` Kernel: ${os.kernel}`)
|
||||||
|
if (os.arch) lines.push(` Architecture: ${os.arch}`)
|
||||||
|
if (uptime?.uptime) lines.push(` Uptime: ${this._formatUptime(uptime.uptime)}`)
|
||||||
|
|
||||||
|
lines.push('')
|
||||||
|
lines.push('Hardware:')
|
||||||
|
if (cpu.brand) {
|
||||||
|
lines.push(` CPU: ${cpu.brand} (${cpu.cores} cores)`)
|
||||||
|
}
|
||||||
|
if (mem.total) {
|
||||||
|
const total = this._formatBytes(mem.total)
|
||||||
|
const used = this._formatBytes(mem.total - (mem.available || 0))
|
||||||
|
const available = this._formatBytes(mem.available || 0)
|
||||||
|
lines.push(` RAM: ${total} total, ${used} used, ${available} available`)
|
||||||
|
}
|
||||||
|
if (graphics.controllers && graphics.controllers.length > 0) {
|
||||||
|
for (const gpu of graphics.controllers) {
|
||||||
|
const vram = gpu.vram ? ` (${gpu.vram} MB VRAM)` : ''
|
||||||
|
lines.push(` GPU: ${gpu.model}${vram}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.push(' GPU: None detected')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disk info — try disk array first, fall back to fsSize
|
||||||
|
const diskEntries = disk.filter((d) => d.totalSize > 0)
|
||||||
|
if (diskEntries.length > 0) {
|
||||||
|
for (const d of diskEntries) {
|
||||||
|
const size = this._formatBytes(d.totalSize)
|
||||||
|
const type = d.tran?.toUpperCase() || (d.rota ? 'HDD' : 'SSD')
|
||||||
|
lines.push(` Disk: ${size}, ${Math.round(d.percentUsed)}% used, ${type}`)
|
||||||
|
}
|
||||||
|
} else if (fsSize.length > 0) {
|
||||||
|
const realFs = fsSize.filter((f) => f.fs.startsWith('/dev/'))
|
||||||
|
const seen = new Set<number>()
|
||||||
|
for (const f of realFs) {
|
||||||
|
if (seen.has(f.size)) continue
|
||||||
|
seen.add(f.size)
|
||||||
|
lines.push(` Disk: ${this._formatBytes(f.size)}, ${Math.round(f.use)}% used`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const installed = services.filter((s) => s.installed)
|
||||||
|
lines.push('')
|
||||||
|
if (installed.length > 0) {
|
||||||
|
lines.push('Installed Services:')
|
||||||
|
for (const svc of installed) {
|
||||||
|
lines.push(` ${svc.friendly_name} (${svc.service_name}): ${svc.status}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.push('Installed Services: None')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (internetStatus !== null) {
|
||||||
|
lines.push('')
|
||||||
|
lines.push(`Internet Status: ${internetStatus ? 'Online' : 'Offline'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (versionCheck?.success) {
|
||||||
|
const updateMsg = versionCheck.updateAvailable
|
||||||
|
? `Yes (${versionCheck.latestVersion} available)`
|
||||||
|
: `No (${versionCheck.currentVersion} is latest)`
|
||||||
|
lines.push(`Update Available: ${updateMsg}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
private _formatUptime(seconds: number): string {
|
||||||
|
const days = Math.floor(seconds / 86400)
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600)
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
|
if (days > 0) return `${days}d ${hours}h ${minutes}m`
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m`
|
||||||
|
return `${minutes}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
private _formatBytes(bytes: number, decimals = 1): string {
|
||||||
|
if (bytes === 0) return '0 Bytes'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
async updateSetting(key: KVStoreKey, value: any): Promise<void> {
|
async updateSetting(key: KVStoreKey, value: any): Promise<void> {
|
||||||
if ((value === '' || value === undefined || value === null) && KV_STORE_SCHEMA[key] === 'string') {
|
if ((value === '' || value === undefined || value === null) && KV_STORE_SCHEMA[key] === 'string') {
|
||||||
await KVStore.clearValue(key)
|
await KVStore.clearValue(key)
|
||||||
|
|
@ -468,10 +579,21 @@ export class SystemService {
|
||||||
return []
|
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
|
return diskLayout.blockdevices
|
||||||
.filter((disk) => disk.type === 'disk') // Only physical disks
|
.filter((disk) => disk.type === 'disk') // Only physical disks
|
||||||
.map((disk) => {
|
.map((disk) => {
|
||||||
const filesystems = getAllFilesystems(disk, fsSize)
|
const filesystems = getAllFilesystems(disk, dedupedFsSize)
|
||||||
|
|
||||||
// Across all partitions
|
// Across all partitions
|
||||||
const totalUsed = filesystems.reduce((sum, p) => sum + (p.used || 0), 0)
|
const totalUsed = filesystems.reduce((sum, p) => sum + (p.used || 0), 0)
|
||||||
|
|
|
||||||
|
|
@ -88,10 +88,29 @@ export async function doResumableDownload({
|
||||||
let lastProgressTime = Date.now()
|
let lastProgressTime = Date.now()
|
||||||
let lastDownloadedBytes = startByte
|
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
|
// Progress tracking stream to monitor data flow
|
||||||
const progressStream = new Transform({
|
const progressStream = new Transform({
|
||||||
transform(chunk: Buffer, _: any, callback: Function) {
|
transform(chunk: Buffer, _: any, callback: Function) {
|
||||||
downloadedBytes += chunk.length
|
downloadedBytes += chunk.length
|
||||||
|
resetStallTimer()
|
||||||
|
|
||||||
// Update progress tracking
|
// Update progress tracking
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
|
|
@ -118,6 +137,7 @@ export async function doResumableDownload({
|
||||||
|
|
||||||
// Handle errors and cleanup
|
// Handle errors and cleanup
|
||||||
const cleanup = (error?: Error) => {
|
const cleanup = (error?: Error) => {
|
||||||
|
clearStallTimer()
|
||||||
progressStream.destroy()
|
progressStream.destroy()
|
||||||
response.data.destroy()
|
response.data.destroy()
|
||||||
writeStream.destroy()
|
writeStream.destroy()
|
||||||
|
|
@ -136,6 +156,7 @@ export async function doResumableDownload({
|
||||||
})
|
})
|
||||||
|
|
||||||
writeStream.on('finish', async () => {
|
writeStream.on('finish', async () => {
|
||||||
|
clearStallTimer()
|
||||||
if (onProgress) {
|
if (onProgress) {
|
||||||
onProgress({
|
onProgress({
|
||||||
downloadedBytes,
|
downloadedBytes,
|
||||||
|
|
@ -151,7 +172,8 @@ export async function doResumableDownload({
|
||||||
resolve(filepath)
|
resolve(filepath)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Pipe: response -> progressStream -> writeStream
|
// Start stall timer and pipe: response -> progressStream -> writeStream
|
||||||
|
resetStallTimer()
|
||||||
response.data.pipe(progressStream).pipe(writeStream)
|
response.data.pipe(progressStream).pipe(writeStream)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,14 +138,13 @@ export function matchesDevice(fsPath: string, deviceName: string): boolean {
|
||||||
// Remove /dev/ and /dev/mapper/ prefixes
|
// Remove /dev/ and /dev/mapper/ prefixes
|
||||||
const normalized = fsPath.replace('/dev/mapper/', '').replace('/dev/', '')
|
const normalized = fsPath.replace('/dev/mapper/', '').replace('/dev/', '')
|
||||||
|
|
||||||
// Direct match
|
// Direct match (covers /dev/sda1 ↔ sda1, /dev/nvme0n1p1 ↔ nvme0n1p1)
|
||||||
if (normalized === deviceName) {
|
if (normalized === deviceName) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// LVM volumes use dashes instead of slashes
|
// LVM/device-mapper: e.g., /dev/mapper/ubuntu--vg-ubuntu--lv contains "ubuntu--lv"
|
||||||
// e.g., ubuntu--vg-ubuntu--lv matches the device name
|
if (fsPath.startsWith('/dev/mapper/') && fsPath.includes(deviceName)) {
|
||||||
if (fsPath.includes(deviceName)) {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,8 +65,23 @@ export default class QueueWork extends BaseCommand {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
worker.on('failed', (job, err) => {
|
worker.on('failed', async (job, err) => {
|
||||||
this.logger.error(`[${queueName}] Job failed: ${job?.id}, Error: ${err.message}`)
|
this.logger.error(`[${queueName}] Job failed: ${job?.id}, Error: ${err.message}`)
|
||||||
|
|
||||||
|
// If this was a Wikipedia download, mark it as failed in the DB
|
||||||
|
if (job?.data?.filetype === 'zim' && job?.data?.url?.includes('wikipedia_en_')) {
|
||||||
|
try {
|
||||||
|
const { DockerService } = await import('#services/docker_service')
|
||||||
|
const { ZimService } = await import('#services/zim_service')
|
||||||
|
const dockerService = new DockerService()
|
||||||
|
const zimService = new ZimService(dockerService)
|
||||||
|
await zimService.onWikipediaDownloadComplete(job.data.url, false)
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error(
|
||||||
|
`[${queueName}] Failed to update Wikipedia status: ${e.message}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
worker.on('completed', (job) => {
|
worker.on('completed', (job) => {
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ const loggerConfig = defineConfig({
|
||||||
targets:
|
targets:
|
||||||
targets()
|
targets()
|
||||||
.pushIf(!app.inProduction, targets.pretty())
|
.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(),
|
.toArray(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { defineConfig } from '@adonisjs/transmit'
|
||||||
import { redis } from '@adonisjs/transmit/transports'
|
import { redis } from '@adonisjs/transmit/transports'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
pingInterval: false,
|
pingInterval: '30s',
|
||||||
transport: {
|
transport: {
|
||||||
driver: redis({
|
driver: redis({
|
||||||
host: env.get('REDIS_HOST'),
|
host: env.get('REDIS_HOST'),
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
import { KVStoreKey } from "../types/kv_store.js";
|
import { KVStoreKey } from "../types/kv_store.js";
|
||||||
|
|
||||||
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'system.earlyAccess', 'ai.assistantCustomName'];
|
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'ui.theme', 'system.earlyAccess', 'ai.assistantCustomName'];
|
||||||
|
|
@ -70,7 +70,7 @@ export default class ServiceSeeder extends BaseSeeder {
|
||||||
display_order: 3,
|
display_order: 3,
|
||||||
description: 'Local AI chat that runs entirely on your hardware - no internet required',
|
description: 'Local AI chat that runs entirely on your hardware - no internet required',
|
||||||
icon: 'IconWand',
|
icon: 'IconWand',
|
||||||
container_image: 'ollama/ollama:0.15.2',
|
container_image: 'ollama/ollama:0.18.1',
|
||||||
source_repo: 'https://github.com/ollama/ollama',
|
source_repo: 'https://github.com/ollama/ollama',
|
||||||
container_command: 'serve',
|
container_command: 'serve',
|
||||||
container_config: JSON.stringify({
|
container_config: JSON.stringify({
|
||||||
|
|
@ -94,7 +94,7 @@ export default class ServiceSeeder extends BaseSeeder {
|
||||||
display_order: 11,
|
display_order: 11,
|
||||||
description: 'Swiss Army knife for data encoding, encryption, and analysis',
|
description: 'Swiss Army knife for data encoding, encryption, and analysis',
|
||||||
icon: 'IconChefHat',
|
icon: 'IconChefHat',
|
||||||
container_image: 'ghcr.io/gchq/cyberchef:10.19.4',
|
container_image: 'ghcr.io/gchq/cyberchef:10.22.1',
|
||||||
source_repo: 'https://github.com/gchq/CyberChef',
|
source_repo: 'https://github.com/gchq/CyberChef',
|
||||||
container_command: null,
|
container_command: null,
|
||||||
container_config: JSON.stringify({
|
container_config: JSON.stringify({
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,12 @@ No — that's the whole point. Once your content is downloaded, everything works
|
||||||
|
|
||||||
### What hardware do I need?
|
### What hardware do I need?
|
||||||
N.O.M.A.D. is designed for capable hardware, especially if you want to use the AI features. Recommended:
|
N.O.M.A.D. is designed for capable hardware, especially if you want to use the AI features. Recommended:
|
||||||
- Modern multi-core CPU
|
- Modern multi-core CPU (AMD Ryzen 7 with Radeon graphics is the community sweet spot)
|
||||||
- 16GB+ RAM (32GB+ for best AI performance)
|
- 16GB+ RAM (32GB+ for best AI performance)
|
||||||
- SSD storage (size depends on content — 500GB minimum, 2TB+ recommended)
|
- SSD storage (size depends on content — 500GB minimum, 1TB+ recommended)
|
||||||
- NVIDIA or AMD GPU recommended for faster AI responses
|
- NVIDIA or AMD GPU recommended for faster AI responses
|
||||||
|
|
||||||
**For detailed build recommendations at three price points ($200–$800+), see the [Hardware Guide](https://www.projectnomad.us/hardware).**
|
**For detailed build recommendations at three price points ($150–$1,000+), see the [Hardware Guide](https://www.projectnomad.us/hardware).**
|
||||||
|
|
||||||
### How much storage do I need?
|
### How much storage do I need?
|
||||||
It depends on what you download:
|
It depends on what you download:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,58 @@
|
||||||
# Release Notes
|
# Release Notes
|
||||||
|
|
||||||
|
## Version 1.30.3 - March 25, 2026
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- **Benchmark**: Fixed an issue where CPU and Disk Write scores could be displayed as 0 if the measured values was less than half of the reference mark. Thanks @bortlesboat for the fix!
|
||||||
|
- **Content Manager**: Fixed a missing API client method that was causing ZIM file deletions to fail. Thanks @LuisMIguelFurlanettoSousa for the fix!
|
||||||
|
- **Install**: Fixed an issue where the install script could incorrectly report the Docker NVIDIA runtime as missing. Thanks @brenex for the fix!
|
||||||
|
- **Support the Project**: Fixed a broken link to Rogue Support. Thanks @chriscrosstalk for the fix!
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- **AI Assistant**: Improved error reporting and handling for model downloads. Thanks @chriscrosstalk for the contribution!
|
||||||
|
- **AI Assistant**: Bumped the default version of Ollama installed to v0.18.1 to take advantage of the latest performance improvements and bug fixes.
|
||||||
|
- **Apps**: Improved error reporting and handling for service installation failures. Thanks @trek-e for the contribution!
|
||||||
|
- **Collections**: Updated various curated collection links to their latest versions. Thanks @builder555 for the contribution!
|
||||||
|
- **Cyberchef**: Bumped the default version of CyberChef installed to v10.22.1 to take advantage of the latest features and bug fixes.
|
||||||
|
- **Docs**: Added a link to the step-by-step installation guide and video tutorial. Thanks @chriscrosstalk for the contribution!
|
||||||
|
- **Install**: Increased the retries limit for the MySQL service in Docker Compose to improve stability during installation on systems with slower performance. Thanks @dx4956 for the contribution!
|
||||||
|
- **Install**: Fixed an issue where stale data could cause credentials mismatch in MySQL on reinstall. Thanks @chriscrosstalk for the fix!
|
||||||
|
|
||||||
|
## Version 1.30.0 - March 20, 2026
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **Night Ops**: Added our most requested feature — a dark mode theme for the Command Center interface! Activate it from the footer and enjoy the sleek new look during your late-night missions. Thanks @chriscrosstalk for the contribution!
|
||||||
|
- **Debug Info**: Added a new "Debug Info" modal accessible from the footer that provides detailed system and application information for troubleshooting and support. Thanks @chriscrosstalk for the contribution!
|
||||||
|
- **Support the Project**: Added a new "Support the Project" page in settings with links to community resources, donation options, and ways to contribute.
|
||||||
|
- **Install**: The main Nomad image is now fully self-contained and directly usable with Docker Compose, allowing for more flexible and customizable installations without relying on external scripts. The image remains fully backwards compatible with existing installations, and the install script has been updated to reflect the simpler deployment process.
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- **Settings**: Storage usage display now prefers real block devices over tempfs. Thanks @Bortlesboat for the fix!
|
||||||
|
- **Settings**: Fixed an issue where device matching and mount entry deduplication logic could cause incorrect storage usage reporting and missing devices in storage displays.
|
||||||
|
- **Maps**: The Maps page now respects the request protocol (http vs https) to ensure map tiles load correctly. Thanks @davidgross for the bug report!
|
||||||
|
- **Knowledge Base**: Fixed an issue where file embedding jobs could cause a retry storm if the Ollama service was unavailable. Thanks @skyam25 for the bug report!
|
||||||
|
- **Curated Collections**: Fixed some broken links in the curated collections definitions (maps and ZIM files) that were causing some resources to fail to download.
|
||||||
|
- **Easy Setup**: Fixed an issue where the "Start Here" badge would persist even after visiting the Easy Setup Wizard for the first time. Thanks @chriscrosstalk for the fix!
|
||||||
|
- **UI**: Fixed an issue where the loading spinner could look strange in certain use cases.
|
||||||
|
- **System Updates**: Fixed an issue where the update banner would persist even after the system was updated successfully. Thanks @chriscrosstalk for the fix!
|
||||||
|
- **Performance**: Various small memory leak fixes and performance improvements across the UI to ensure a smoother experience.
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- **Ollama**: Improved GPU detection logic to ensure the latest GPU config is always passed to the Ollama container on update
|
||||||
|
- **Ollama**: The detected GPU type is now persisted in the database for more reliable configuration and troubleshooting across updates and restarts. Thanks @chriscrosstalk for the contribution!
|
||||||
|
- **Downloads**: Users can now dismiss failed download notifications to reduce clutter in the UI. Thanks @chriscrosstalk for the contribution!
|
||||||
|
- **Logging**: Changed the default log level to "info" to reduce noise and focus on important messages. Thanks @traxeon for the suggestion!
|
||||||
|
- **Logging**: Nomad's internal logger now creates it's own log directory on startup if it doesn't already exist to prevent errors on fresh installs where the logs directory hasn't been created yet.
|
||||||
|
- **Dozzle**: Dozzle shell access and container actions are now disabled by default. Thanks @traxeon for the recommendation!
|
||||||
|
- **MySQL & Redis**: Removed port exposure to host by default for improved security. Ports can still be exposed manually if needed. Thanks @traxeon for the recommendation!
|
||||||
|
- **Dependencies**: Various dependency updates to close security vulnerabilities and improve stability
|
||||||
|
- **Utility Scripts**: Added a check for the expected Docker Compose version (v2) in all utility scripts to provide clearer error messages and guidance if the environment is not set up correctly.
|
||||||
|
- **Utility Scripts**: Added an additional warning to the installation script to inform about potential overwriting of existing customized configurations and the importance of backing up data before running the installation script again.
|
||||||
|
- **Documentation**: Updated installation instructions to reflect the new option for manual deployment via Docker Compose without the install script.
|
||||||
|
|
||||||
|
|
||||||
## Version 1.29.0 - March 11, 2026
|
## Version 1.29.0 - March 11, 2026
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
|
||||||
|
|
@ -1,281 +0,0 @@
|
||||||
# Project NOMAD Security Audit Report
|
|
||||||
|
|
||||||
**Date:** 2026-03-08
|
|
||||||
**Version audited:** v1.28.0 (main branch)
|
|
||||||
**Auditor:** Claude Code (automated + manual review)
|
|
||||||
**Target:** Pre-launch security review
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
Project NOMAD's codebase is **reasonably clean for a LAN appliance**, with no critical authentication bypasses or remote code execution vulnerabilities. However, there are **4 findings that should be fixed before public launch** — all are straightforward path traversal and SSRF issues with known fix patterns already used elsewhere in the codebase.
|
|
||||||
|
|
||||||
| Severity | Count | Summary |
|
|
||||||
|----------|-------|---------|
|
|
||||||
| **HIGH** | 4 | Path traversal (3), SSRF (1) |
|
|
||||||
| **MEDIUM** | 5 | Dozzle shell, unvalidated settings read, content update URL injection, verbose errors, no rate limiting |
|
|
||||||
| **LOW** | 5 | CSRF disabled, CORS wildcard, debug logging, npm dep CVEs, hardcoded HMAC |
|
|
||||||
| **INFO** | 2 | No auth by design, Docker socket exposure by design |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scans Performed
|
|
||||||
|
|
||||||
| Scan | Tool | Result |
|
|
||||||
|------|------|--------|
|
|
||||||
| Dependency audit | `npm audit` | 2 CVEs (1 high, 1 moderate) |
|
|
||||||
| Secret scan | Manual grep (passwords, keys, tokens, certs) | Clean — all secrets from env vars |
|
|
||||||
| SAST | Semgrep (security-audit, OWASP, nodejs rulesets) | 0 findings (AdonisJS not in rulesets) |
|
|
||||||
| Docker config review | Manual review of compose, Dockerfiles, scripts | 2 actionable findings |
|
|
||||||
| Code review | Manual review of services, controllers, validators | 4 path traversal + 1 SSRF |
|
|
||||||
| API endpoint audit | Manual review of all 60+ routes | Attack surface documented |
|
|
||||||
| DAST (OWASP ZAP) | Skipped — Docker Desktop not running | Recommended as follow-up |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## FIX BEFORE LAUNCH
|
|
||||||
|
|
||||||
### 1. Path Traversal — ZIM File Delete (HIGH)
|
|
||||||
|
|
||||||
**File:** `admin/app/services/zim_service.ts:329-342`
|
|
||||||
**Endpoint:** `DELETE /api/zim/:filename`
|
|
||||||
|
|
||||||
The `filename` parameter flows into `path.join()` with no directory containment check. An attacker can delete `.zim` files outside the storage directory:
|
|
||||||
|
|
||||||
```
|
|
||||||
DELETE /api/zim/..%2F..%2Fsome-file.zim
|
|
||||||
```
|
|
||||||
|
|
||||||
**Fix:** Resolve the full path and verify it starts with the expected storage directory:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
async delete(file: string): Promise<void> {
|
|
||||||
let fileName = file
|
|
||||||
if (!fileName.endsWith('.zim')) {
|
|
||||||
fileName += '.zim'
|
|
||||||
}
|
|
||||||
|
|
||||||
const basePath = join(process.cwd(), ZIM_STORAGE_PATH)
|
|
||||||
const fullPath = resolve(basePath, fileName)
|
|
||||||
|
|
||||||
// Prevent path traversal
|
|
||||||
if (!fullPath.startsWith(basePath)) {
|
|
||||||
throw new Error('Invalid filename')
|
|
||||||
}
|
|
||||||
|
|
||||||
// ... rest of delete logic
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This pattern is already used correctly in `rag_service.ts:deleteFileBySource()`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Path Traversal — Map File Delete (HIGH)
|
|
||||||
|
|
||||||
**File:** `admin/app/services/map_service.ts` (delete method)
|
|
||||||
**Endpoint:** `DELETE /api/maps/:filename`
|
|
||||||
|
|
||||||
Identical pattern to the ZIM delete. Same fix — resolve path, verify `startsWith(basePath)`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Path Traversal — Documentation Read (HIGH)
|
|
||||||
|
|
||||||
**File:** `admin/app/services/docs_service.ts:61-83`
|
|
||||||
**Endpoint:** `GET /docs/:slug`
|
|
||||||
|
|
||||||
The `slug` parameter flows into `path.join(this.docsPath, filename)` with no containment check. An attacker can read arbitrary `.md` files on the filesystem:
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /docs/..%2F..%2F..%2Fetc%2Fpasswd
|
|
||||||
```
|
|
||||||
|
|
||||||
Limited by the mandatory `.md` extension, but could still read sensitive markdown files outside the docs directory (like CLAUDE.md, README.md, etc.).
|
|
||||||
|
|
||||||
**Fix:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const basePath = this.docsPath
|
|
||||||
const fullPath = path.resolve(basePath, filename)
|
|
||||||
|
|
||||||
if (!fullPath.startsWith(path.resolve(basePath))) {
|
|
||||||
throw new Error('Invalid document slug')
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. SSRF — Download Endpoints (HIGH)
|
|
||||||
|
|
||||||
**File:** `admin/app/validators/common.ts`
|
|
||||||
**Endpoints:** `POST /api/zim/download-remote`, `POST /api/maps/download-remote`, `POST /api/maps/download-base-assets`, `POST /api/maps/download-remote-preflight`
|
|
||||||
|
|
||||||
The download endpoints accept user-supplied URLs and the server fetches from them. Without validation, an attacker on the LAN (or via CSRF since `shield.ts` disables CSRF protection) could make NOMAD fetch from co-located services:
|
|
||||||
- `http://localhost:3306` (MySQL)
|
|
||||||
- `http://localhost:6379` (Redis)
|
|
||||||
- `http://169.254.169.254/` (cloud metadata — if NOMAD is ever cloud-hosted)
|
|
||||||
|
|
||||||
**Fix:** Added `assertNotPrivateUrl()` that blocks loopback and link-local addresses before any download is initiated. Called in all download controllers.
|
|
||||||
|
|
||||||
**Scope note:** RFC1918 private addresses (10.x, 172.16-31.x, 192.168.x) are intentionally **allowed** because NOMAD is a LAN appliance and users may host content mirrors on their local network. The `require_tld: false` VineJS option is preserved so URLs like `http://my-nas:8080/file.zim` remain valid.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const blockedPatterns = [
|
|
||||||
/^localhost$/,
|
|
||||||
/^127\.\d+\.\d+\.\d+$/,
|
|
||||||
/^0\.0\.0\.0$/,
|
|
||||||
/^169\.254\.\d+\.\d+$/, // Link-local / cloud metadata
|
|
||||||
/^\[::1\]$/,
|
|
||||||
/^\[?fe80:/i, // IPv6 link-local
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## FIX AFTER LAUNCH (Medium Priority)
|
|
||||||
|
|
||||||
### 5. Dozzle Web Shell Access (MEDIUM)
|
|
||||||
|
|
||||||
**File:** `install/management_compose.yaml:56`
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- DOZZLE_ENABLE_SHELL=true
|
|
||||||
```
|
|
||||||
|
|
||||||
Dozzle on port 9999 is bound to all interfaces with shell access enabled. Anyone on the LAN can open a web shell into containers, including `nomad_admin` which has the Docker socket mounted. This creates a path from "LAN access" → "container shell" → "Docker socket" → "host root."
|
|
||||||
|
|
||||||
**Fix:** Set `DOZZLE_ENABLE_SHELL=false`. Log viewing and container restart functionality are preserved.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. Unvalidated Settings Key Read (MEDIUM)
|
|
||||||
|
|
||||||
**File:** `admin/app/controllers/settings_controller.ts`
|
|
||||||
**Endpoint:** `GET /api/system/settings?key=...`
|
|
||||||
|
|
||||||
The `updateSetting` endpoint validates the key against an enum, but `getSetting` accepts any arbitrary key string. Currently harmless since the KV store only contains settings data, but could leak sensitive info if new keys are added.
|
|
||||||
|
|
||||||
**Fix:** Apply the same enum validation to the read endpoint.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 7. Content Update URL Injection (MEDIUM)
|
|
||||||
|
|
||||||
**File:** `admin/app/validators/common.ts:72-88`
|
|
||||||
**Endpoint:** `POST /api/content-updates/apply`
|
|
||||||
|
|
||||||
The `download_url` comes directly from the client request body. An attacker can supply any URL and NOMAD will download from it. The URL should be looked up server-side from the content manifest instead.
|
|
||||||
|
|
||||||
**Fix:** Validate `download_url` against the cached manifest, or apply the same loopback/link-local protections as finding #4 (already applied in this PR).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 8. Verbose Error Messages (MEDIUM)
|
|
||||||
|
|
||||||
**Files:** `rag_controller.ts`, `docker_service.ts`, `system_update_service.ts`
|
|
||||||
|
|
||||||
Several controllers return raw `error.message` in API responses, potentially leaking internal paths, stack details, or Docker error messages to the client.
|
|
||||||
|
|
||||||
**Fix:** Return generic error messages in production. Log the details server-side.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 9. No Rate Limiting (MEDIUM)
|
|
||||||
|
|
||||||
Zero rate limiting across all 60+ endpoints. While acceptable for a LAN appliance, some endpoints are particularly abusable:
|
|
||||||
- `POST /api/benchmark/run` — spins up Docker containers for CPU/memory/disk stress tests
|
|
||||||
- `POST /api/rag/upload` — file uploads (20MB limit per bodyparser config)
|
|
||||||
- `POST /api/system/services/affect` — can stop/start any service repeatedly
|
|
||||||
|
|
||||||
**Fix:** Consider basic rate limiting on the benchmark and service control endpoints (e.g., 1 benchmark per minute, service actions throttled to prevent rapid cycling).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## LOW PRIORITY / ACCEPTED RISK
|
|
||||||
|
|
||||||
### 10. CSRF Protection Disabled (LOW)
|
|
||||||
|
|
||||||
**File:** `admin/config/shield.ts`
|
|
||||||
|
|
||||||
CSRF is disabled, meaning any website a LAN user visits could fire requests at NOMAD's API. This amplifies findings 1-4 — path traversal and SSRF could be triggered by a malicious webpage, not just direct LAN access.
|
|
||||||
|
|
||||||
**Assessment:** Acceptable for a LAN appliance with no auth system. Enabling CSRF would require significant auth/session infrastructure changes.
|
|
||||||
|
|
||||||
### 11. CORS Wildcard with Credentials (LOW)
|
|
||||||
|
|
||||||
**File:** `admin/config/cors.ts`
|
|
||||||
|
|
||||||
`origin: ['*']` with `credentials: true`. Standard for LAN appliances.
|
|
||||||
|
|
||||||
### 12. npm Dependency CVEs (LOW)
|
|
||||||
|
|
||||||
```
|
|
||||||
tar <=7.5.9 HIGH Hardlink Path Traversal via Drive-Relative Linkpath
|
|
||||||
ajv <6.14.0 MODERATE ReDoS when using $data option
|
|
||||||
```
|
|
||||||
|
|
||||||
Both fixable via `npm audit fix`. Low practical risk since these are build/dev dependencies not directly exposed to user input.
|
|
||||||
|
|
||||||
**Fix:** Run `npm audit fix` and commit the updated lockfile.
|
|
||||||
|
|
||||||
### 13. Hardcoded HMAC Secret (LOW)
|
|
||||||
|
|
||||||
**File:** `admin/app/services/benchmark_service.ts:35`
|
|
||||||
|
|
||||||
The benchmark HMAC secret `'nomad-benchmark-v1-2026'` is hardcoded in open-source code. Anyone can forge leaderboard submissions.
|
|
||||||
|
|
||||||
**Assessment:** Accepted risk. The leaderboard has compensating controls (rate limiting, plausibility validation, hardware fingerprint dedup). The secret stops casual abuse, not determined attackers.
|
|
||||||
|
|
||||||
### 14. Production Debug Logging (LOW)
|
|
||||||
|
|
||||||
**File:** `install/management_compose.yaml:22`
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
LOG_LEVEL=debug
|
|
||||||
```
|
|
||||||
|
|
||||||
Debug logging in production can expose internal state in log files.
|
|
||||||
|
|
||||||
**Fix:** Change to `LOG_LEVEL=info` for production compose template.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## INFORMATIONAL (By Design)
|
|
||||||
|
|
||||||
### No Authentication
|
|
||||||
|
|
||||||
All 60+ API endpoints are unauthenticated. This is by design — NOMAD is a LAN appliance and the network boundary is the access control. Issue #73 tracks the edge case of public IP interfaces.
|
|
||||||
|
|
||||||
### Docker Socket Exposure
|
|
||||||
|
|
||||||
The `nomad_admin` container mounts `/var/run/docker.sock`. This is necessary for NOMAD's core functionality (managing Docker containers). The socket is not exposed to the network — only the admin container can use it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recommendations Summary
|
|
||||||
|
|
||||||
| Priority | Action | Effort |
|
|
||||||
|----------|--------|--------|
|
|
||||||
| **Before launch** | Fix 3 path traversals (ZIM delete, Map delete, Docs read) | ~30 min |
|
|
||||||
| **Before launch** | Add SSRF protection to download URL validators | ~1 hour |
|
|
||||||
| **Soon after** | Disable Dozzle shell access | 1 line change |
|
|
||||||
| **Soon after** | Validate settings key on read endpoint | ~15 min |
|
|
||||||
| **Soon after** | Sanitize error messages in responses | ~30 min |
|
|
||||||
| **Nice to have** | Run `npm audit fix` | 5 min |
|
|
||||||
| **Nice to have** | Change production log level to info | 1 line change |
|
|
||||||
| **Follow-up** | OWASP ZAP dynamic scan against NOMAD3 | ~1 hour |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What Went Right
|
|
||||||
|
|
||||||
- **No hardcoded secrets** — all credentials properly use environment variables
|
|
||||||
- **No command injection** — Docker operations use the Docker API (dockerode), not shell commands
|
|
||||||
- **No SQL injection** — all database queries use AdonisJS Lucid ORM with parameterized queries
|
|
||||||
- **No eval/Function** — no dynamic code execution anywhere
|
|
||||||
- **RAG service already has the correct fix pattern** — `deleteFileBySource()` uses `resolve()` + `startsWith()` for path containment
|
|
||||||
- **Install script generates strong random passwords** — uses `/dev/urandom` for APP_KEY and DB passwords
|
|
||||||
- **No privileged containers** — GPU passthrough uses DeviceRequests, not --privileged
|
|
||||||
- **Health checks don't leak data** — internal-only calls
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { generateUUID } from '~/lib/util'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||||
import NotificationsProvider from '~/providers/NotificationProvider'
|
import NotificationsProvider from '~/providers/NotificationProvider'
|
||||||
|
import { ThemeProvider } from '~/providers/ThemeProvider'
|
||||||
import { UsePageProps } from '../../types/system'
|
import { UsePageProps } from '../../types/system'
|
||||||
|
|
||||||
const appName = import.meta.env.VITE_APP_NAME || 'Project N.O.M.A.D.'
|
const appName = import.meta.env.VITE_APP_NAME || 'Project N.O.M.A.D.'
|
||||||
|
|
@ -38,14 +39,16 @@ createInertiaApp({
|
||||||
const showDevtools = ['development', 'staging'].includes(environment)
|
const showDevtools = ['development', 'staging'].includes(environment)
|
||||||
createRoot(el).render(
|
createRoot(el).render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<TransmitProvider baseUrl={window.location.origin} enableLogging={true}>
|
<ThemeProvider>
|
||||||
<NotificationsProvider>
|
<TransmitProvider baseUrl={window.location.origin} enableLogging={environment === 'development'}>
|
||||||
<ModalsProvider>
|
<NotificationsProvider>
|
||||||
<App {...props} />
|
<ModalsProvider>
|
||||||
{showDevtools && <ReactQueryDevtools initialIsOpen={false} buttonPosition='bottom-left' />}
|
<App {...props} />
|
||||||
</ModalsProvider>
|
{showDevtools && <ReactQueryDevtools initialIsOpen={false} buttonPosition='bottom-left' />}
|
||||||
</NotificationsProvider>
|
</ModalsProvider>
|
||||||
</TransmitProvider>
|
</NotificationsProvider>
|
||||||
|
</TransmitProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import useDownloads, { useDownloadsProps } from '~/hooks/useDownloads'
|
||||||
import HorizontalBarChart from './HorizontalBarChart'
|
import HorizontalBarChart from './HorizontalBarChart'
|
||||||
import { extractFileName } from '~/lib/util'
|
import { extractFileName } from '~/lib/util'
|
||||||
import StyledSectionHeader from './StyledSectionHeader'
|
import StyledSectionHeader from './StyledSectionHeader'
|
||||||
|
import { IconAlertTriangle, IconX } from '@tabler/icons-react'
|
||||||
|
import api from '~/lib/api'
|
||||||
|
|
||||||
interface ActiveDownloadProps {
|
interface ActiveDownloadProps {
|
||||||
filetype?: useDownloadsProps['filetype']
|
filetype?: useDownloadsProps['filetype']
|
||||||
|
|
@ -9,7 +11,12 @@ interface ActiveDownloadProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps) => {
|
const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps) => {
|
||||||
const { data: downloads } = useDownloads({ filetype })
|
const { data: downloads, invalidate } = useDownloads({ filetype })
|
||||||
|
|
||||||
|
const handleDismiss = async (jobId: string) => {
|
||||||
|
await api.removeDownloadJob(jobId)
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -17,22 +24,50 @@ const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps)
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{downloads && downloads.length > 0 ? (
|
{downloads && downloads.length > 0 ? (
|
||||||
downloads.map((download) => (
|
downloads.map((download) => (
|
||||||
<div className="bg-desert-white rounded-lg p-4 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow">
|
<div
|
||||||
<HorizontalBarChart
|
key={download.jobId}
|
||||||
items={[
|
className={`bg-desert-white rounded-lg p-4 border shadow-sm hover:shadow-lg transition-shadow ${
|
||||||
{
|
download.status === 'failed'
|
||||||
label: extractFileName(download.filepath) || download.url,
|
? 'border-red-300'
|
||||||
value: download.progress,
|
: 'border-desert-stone-light'
|
||||||
total: '100%',
|
}`}
|
||||||
used: `${download.progress}%`,
|
>
|
||||||
type: download.filetype,
|
{download.status === 'failed' ? (
|
||||||
},
|
<div className="flex items-center gap-2">
|
||||||
]}
|
<IconAlertTriangle className="w-5 h-5 text-red-500 flex-shrink-0" />
|
||||||
/>
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 truncate">
|
||||||
|
{extractFileName(download.filepath) || download.url}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-red-600 mt-0.5">
|
||||||
|
Download failed{download.failedReason ? `: ${download.failedReason}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDismiss(download.jobId)}
|
||||||
|
className="flex-shrink-0 p-1 rounded hover:bg-red-100 transition-colors"
|
||||||
|
title="Dismiss failed download"
|
||||||
|
>
|
||||||
|
<IconX className="w-4 h-4 text-red-400 hover:text-red-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<HorizontalBarChart
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: extractFileName(download.filepath) || download.url,
|
||||||
|
value: download.progress,
|
||||||
|
total: '100%',
|
||||||
|
used: `${download.progress}%`,
|
||||||
|
type: download.filetype,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-500">No active downloads</p>
|
<p className="text-text-muted">No active downloads</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ const ActiveEmbedJobs = ({ withHeader = false }: ActiveEmbedJobsProps) => {
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-500">No files are currently being processed</p>
|
<p className="text-text-muted">No files are currently being processed</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import useOllamaModelDownloads from '~/hooks/useOllamaModelDownloads'
|
import useOllamaModelDownloads from '~/hooks/useOllamaModelDownloads'
|
||||||
import HorizontalBarChart from './HorizontalBarChart'
|
import HorizontalBarChart from './HorizontalBarChart'
|
||||||
import StyledSectionHeader from './StyledSectionHeader'
|
import StyledSectionHeader from './StyledSectionHeader'
|
||||||
|
import { IconAlertTriangle } from '@tabler/icons-react'
|
||||||
|
|
||||||
interface ActiveModelDownloadsProps {
|
interface ActiveModelDownloadsProps {
|
||||||
withHeader?: boolean
|
withHeader?: boolean
|
||||||
|
|
@ -17,23 +18,35 @@ const ActiveModelDownloads = ({ withHeader = false }: ActiveModelDownloadsProps)
|
||||||
downloads.map((download) => (
|
downloads.map((download) => (
|
||||||
<div
|
<div
|
||||||
key={download.model}
|
key={download.model}
|
||||||
className="bg-desert-white rounded-lg p-4 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow"
|
className={`bg-desert-white rounded-lg p-4 border shadow-sm hover:shadow-lg transition-shadow ${
|
||||||
|
download.error ? 'border-red-400' : 'border-desert-stone-light'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<HorizontalBarChart
|
{download.error ? (
|
||||||
items={[
|
<div className="flex items-start gap-3">
|
||||||
{
|
<IconAlertTriangle className="text-red-500 flex-shrink-0 mt-0.5" size={20} />
|
||||||
label: download.model,
|
<div>
|
||||||
value: download.percent,
|
<p className="font-medium text-text-primary">{download.model}</p>
|
||||||
total: '100%',
|
<p className="text-sm text-red-600 mt-1">{download.error}</p>
|
||||||
used: `${download.percent.toFixed(1)}%`,
|
</div>
|
||||||
type: 'ollama-model',
|
</div>
|
||||||
},
|
) : (
|
||||||
]}
|
<HorizontalBarChart
|
||||||
/>
|
items={[
|
||||||
|
{
|
||||||
|
label: download.model,
|
||||||
|
value: download.percent,
|
||||||
|
total: '100%',
|
||||||
|
used: `${download.percent.toFixed(1)}%`,
|
||||||
|
type: 'ollama-model',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-500">No active model downloads</p>
|
<p className="text-text-muted">No active model downloads</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ export default function Alert({
|
||||||
}
|
}
|
||||||
|
|
||||||
const getIconColor = () => {
|
const getIconColor = () => {
|
||||||
if (variant === 'solid') return 'text-desert-white'
|
if (variant === 'solid') return 'text-white'
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'warning':
|
case 'warning':
|
||||||
return 'text-desert-orange'
|
return 'text-desert-orange'
|
||||||
|
|
@ -81,15 +81,15 @@ export default function Alert({
|
||||||
case 'solid':
|
case 'solid':
|
||||||
variantStyles.push(
|
variantStyles.push(
|
||||||
type === 'warning'
|
type === 'warning'
|
||||||
? 'bg-desert-orange text-desert-white border border-desert-orange-dark'
|
? 'bg-desert-orange text-white border border-desert-orange-dark'
|
||||||
: type === 'error'
|
: type === 'error'
|
||||||
? 'bg-desert-red text-desert-white border border-desert-red-dark'
|
? 'bg-desert-red text-white border border-desert-red-dark'
|
||||||
: type === 'success'
|
: type === 'success'
|
||||||
? 'bg-desert-olive text-desert-white border border-desert-olive-dark'
|
? 'bg-desert-olive text-white border border-desert-olive-dark'
|
||||||
: type === 'info'
|
: type === 'info'
|
||||||
? 'bg-desert-green text-desert-white border border-desert-green-dark'
|
? 'bg-desert-green text-white border border-desert-green-dark'
|
||||||
: type === 'info-inverted'
|
: type === 'info-inverted'
|
||||||
? 'bg-desert-tan text-desert-white border border-desert-tan-dark'
|
? 'bg-desert-tan text-white border border-desert-tan-dark'
|
||||||
: ''
|
: ''
|
||||||
)
|
)
|
||||||
return classNames(baseStyles, 'shadow-lg', ...variantStyles)
|
return classNames(baseStyles, 'shadow-lg', ...variantStyles)
|
||||||
|
|
@ -112,7 +112,7 @@ export default function Alert({
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTitleColor = () => {
|
const getTitleColor = () => {
|
||||||
if (variant === 'solid') return 'text-desert-white'
|
if (variant === 'solid') return 'text-white'
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'warning':
|
case 'warning':
|
||||||
|
|
@ -131,7 +131,7 @@ export default function Alert({
|
||||||
}
|
}
|
||||||
|
|
||||||
const getMessageColor = () => {
|
const getMessageColor = () => {
|
||||||
if (variant === 'solid') return 'text-desert-white text-opacity-90'
|
if (variant === 'solid') return 'text-white text-opacity-90'
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'warning':
|
case 'warning':
|
||||||
|
|
@ -149,7 +149,7 @@ export default function Alert({
|
||||||
|
|
||||||
const getCloseButtonStyles = () => {
|
const getCloseButtonStyles = () => {
|
||||||
if (variant === 'solid') {
|
if (variant === 'solid') {
|
||||||
return 'text-desert-white hover:text-desert-white hover:bg-black hover:bg-opacity-20'
|
return 'text-white hover:text-white hover:bg-black hover:bg-opacity-20'
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
|
|
||||||
|
|
@ -9,18 +9,18 @@ interface BouncingDotsProps {
|
||||||
export default function BouncingDots({ text, containerClassName, textClassName }: BouncingDotsProps) {
|
export default function BouncingDots({ text, containerClassName, textClassName }: BouncingDotsProps) {
|
||||||
return (
|
return (
|
||||||
<div className={clsx("flex items-center justify-center gap-2", containerClassName)}>
|
<div className={clsx("flex items-center justify-center gap-2", containerClassName)}>
|
||||||
<span className={clsx("text-gray-600", textClassName)}>{text}</span>
|
<span className={clsx("text-text-secondary", textClassName)}>{text}</span>
|
||||||
<span className="flex gap-1 mt-1">
|
<span className="flex gap-1 mt-1">
|
||||||
<span
|
<span
|
||||||
className="w-1.5 h-1.5 bg-gray-600 rounded-full animate-bounce"
|
className="w-1.5 h-1.5 bg-text-secondary rounded-full animate-bounce"
|
||||||
style={{ animationDelay: '0ms' }}
|
style={{ animationDelay: '0ms' }}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="w-1.5 h-1.5 bg-gray-600 rounded-full animate-bounce"
|
className="w-1.5 h-1.5 bg-text-secondary rounded-full animate-bounce"
|
||||||
style={{ animationDelay: '150ms' }}
|
style={{ animationDelay: '150ms' }}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="w-1.5 h-1.5 bg-gray-600 rounded-full animate-bounce"
|
className="w-1.5 h-1.5 bg-text-secondary rounded-full animate-bounce"
|
||||||
style={{ animationDelay: '300ms' }}
|
style={{ animationDelay: '300ms' }}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
103
admin/inertia/components/DebugInfoModal.tsx
Normal file
103
admin/inertia/components/DebugInfoModal.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { IconBug, IconCopy, IconCheck } from '@tabler/icons-react'
|
||||||
|
import StyledModal from './StyledModal'
|
||||||
|
import api from '~/lib/api'
|
||||||
|
|
||||||
|
interface DebugInfoModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DebugInfoModal({ open, onClose }: DebugInfoModalProps) {
|
||||||
|
const [debugText, setDebugText] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setCopied(false)
|
||||||
|
|
||||||
|
api.getDebugInfo().then((text) => {
|
||||||
|
if (text) {
|
||||||
|
const browserLine = `Browser: ${navigator.userAgent}`
|
||||||
|
setDebugText(text + '\n' + browserLine)
|
||||||
|
} else {
|
||||||
|
setDebugText('Failed to load debug info. Please try again.')
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
}).catch(() => {
|
||||||
|
setDebugText('Failed to load debug info. Please try again.')
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(debugText)
|
||||||
|
} catch {
|
||||||
|
// Fallback for older browsers
|
||||||
|
const textarea = document.querySelector<HTMLTextAreaElement>('#debug-info-text')
|
||||||
|
if (textarea) {
|
||||||
|
textarea.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledModal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Debug Info"
|
||||||
|
icon={<IconBug className="size-8 text-desert-green" />}
|
||||||
|
cancelText="Close"
|
||||||
|
onCancel={onClose}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-gray-500 mb-3 text-left">
|
||||||
|
This is non-sensitive system info you can share when reporting issues.
|
||||||
|
No passwords, IPs, or API keys are included.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
id="debug-info-text"
|
||||||
|
readOnly
|
||||||
|
value={loading ? 'Loading...' : debugText}
|
||||||
|
rows={18}
|
||||||
|
className="w-full font-mono text-xs text-black bg-gray-50 border border-gray-200 rounded-md p-3 resize-none focus:outline-none text-left"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md bg-desert-green px-3 py-1.5 text-sm font-semibold text-white hover:bg-desert-green-dark transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<IconCheck className="size-4" />
|
||||||
|
Copied!
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<IconCopy className="size-4" />
|
||||||
|
Copy to Clipboard
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://github.com/Crosstalk-Solutions/project-nomad/issues"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-desert-green hover:underline"
|
||||||
|
>
|
||||||
|
Open a GitHub Issue
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</StyledModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -63,7 +63,7 @@ const DownloadURLModal: React.FC<DownloadURLModalProps> = ({
|
||||||
large
|
large
|
||||||
>
|
>
|
||||||
<div className="flex flex-col pb-4">
|
<div className="flex flex-col pb-4">
|
||||||
<p className="text-gray-700 mb-8">
|
<p className="text-text-secondary mb-8">
|
||||||
Enter the URL of the map region file you wish to download. The URL must be publicly
|
Enter the URL of the map region file you wish to download. The URL must be publicly
|
||||||
reachable and end with .pmtiles. A preflight check will be run to verify the file's
|
reachable and end with .pmtiles. A preflight check will be run to verify the file's
|
||||||
availability, type, and approximate size.
|
availability, type, and approximate size.
|
||||||
|
|
@ -76,11 +76,11 @@ const DownloadURLModal: React.FC<DownloadURLModalProps> = ({
|
||||||
value={url}
|
value={url}
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="min-h-24 max-h-96 overflow-y-auto bg-gray-50 p-4 rounded border border-gray-300 text-left">
|
<div className="min-h-24 max-h-96 overflow-y-auto bg-surface-secondary p-4 rounded border border-border-default text-left">
|
||||||
{messages.map((message, idx) => (
|
{messages.map((message, idx) => (
|
||||||
<p
|
<p
|
||||||
key={idx}
|
key={idx}
|
||||||
className="text-sm text-gray-900 font-mono leading-relaxed break-words mb-3"
|
className="text-sm text-text-primary font-mono leading-relaxed break-words mb-3"
|
||||||
>
|
>
|
||||||
{message}
|
{message}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,31 @@
|
||||||
|
import { useState } from 'react'
|
||||||
import { usePage } from '@inertiajs/react'
|
import { usePage } from '@inertiajs/react'
|
||||||
import { UsePageProps } from '../../types/system'
|
import { UsePageProps } from '../../types/system'
|
||||||
|
import ThemeToggle from '~/components/ThemeToggle'
|
||||||
|
import { IconBug } from '@tabler/icons-react'
|
||||||
|
import DebugInfoModal from './DebugInfoModal'
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
const { appVersion } = usePage().props as unknown as UsePageProps
|
const { appVersion } = usePage().props as unknown as UsePageProps
|
||||||
|
const [debugModalOpen, setDebugModalOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="">
|
<footer>
|
||||||
<div className="flex justify-center border-t border-gray-900/10 py-4">
|
<div className="flex items-center justify-center gap-3 border-t border-border-subtle py-4">
|
||||||
<p className="text-sm/6 text-gray-600">
|
<p className="text-sm/6 text-text-secondary">
|
||||||
Project N.O.M.A.D. Command Center v{appVersion}
|
Project N.O.M.A.D. Command Center v{appVersion}
|
||||||
</p>
|
</p>
|
||||||
|
<span className="text-gray-300">|</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setDebugModalOpen(true)}
|
||||||
|
className="text-sm/6 text-gray-500 hover:text-desert-green flex items-center gap-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
<IconBug className="size-3.5" />
|
||||||
|
Debug Info
|
||||||
|
</button>
|
||||||
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
|
<DebugInfoModal open={debugModalOpen} onClose={() => setDebugModalOpen(false)} />
|
||||||
</footer>
|
</footer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ export default function HorizontalBarChart({
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'absolute top-1/2 -translate-y-1/2 font-bold text-sm',
|
'absolute top-1/2 -translate-y-1/2 font-bold text-sm',
|
||||||
item.value > 15
|
item.value > 15
|
||||||
? 'left-3 text-desert-white drop-shadow-md'
|
? 'left-3 text-white drop-shadow-md'
|
||||||
: 'right-3 text-desert-green'
|
: 'right-3 text-desert-green'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@ export type InstallActivityFeedProps = {
|
||||||
|
|
||||||
const InstallActivityFeed: React.FC<InstallActivityFeedProps> = ({ activity, className, withHeader = false }) => {
|
const InstallActivityFeed: React.FC<InstallActivityFeedProps> = ({ activity, className, withHeader = false }) => {
|
||||||
return (
|
return (
|
||||||
<div className={classNames('bg-white shadow-sm rounded-lg p-6', className)}>
|
<div className={classNames('bg-surface-primary shadow-sm rounded-lg p-6', className)}>
|
||||||
{withHeader && <h2 className="text-lg font-semibold text-gray-900">Installation Activity</h2>}
|
{withHeader && <h2 className="text-lg font-semibold text-text-primary">Installation Activity</h2>}
|
||||||
<ul role="list" className={classNames("space-y-6 text-desert-green", withHeader ? 'mt-6' : '')}>
|
<ul role="list" className={classNames("space-y-6 text-desert-green", withHeader ? 'mt-6' : '')}>
|
||||||
{activity.map((activityItem, activityItemIdx) => (
|
{activity.map((activityItem, activityItemIdx) => (
|
||||||
<li key={activityItem.timestamp} className="relative flex gap-x-4">
|
<li key={activityItem.timestamp} className="relative flex gap-x-4">
|
||||||
|
|
@ -42,7 +42,7 @@ const InstallActivityFeed: React.FC<InstallActivityFeedProps> = ({ activity, cla
|
||||||
'absolute left-0 top-0 flex w-6 justify-center'
|
'absolute left-0 top-0 flex w-6 justify-center'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="w-px bg-gray-200" />
|
<div className="w-px bg-border-subtle" />
|
||||||
</div>
|
</div>
|
||||||
<>
|
<>
|
||||||
<div className="relative flex size-6 flex-none items-center justify-center bg-transparent">
|
<div className="relative flex size-6 flex-none items-center justify-center bg-transparent">
|
||||||
|
|
@ -51,16 +51,16 @@ const InstallActivityFeed: React.FC<InstallActivityFeedProps> = ({ activity, cla
|
||||||
) : activityItem.type === 'update-rollback' ? (
|
) : activityItem.type === 'update-rollback' ? (
|
||||||
<IconCircleX aria-hidden="true" className="size-6 text-red-500" />
|
<IconCircleX aria-hidden="true" className="size-6 text-red-500" />
|
||||||
) : (
|
) : (
|
||||||
<div className="size-1.5 rounded-full bg-gray-100 ring-1 ring-gray-300" />
|
<div className="size-1.5 rounded-full bg-surface-secondary ring-1 ring-border-default" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="flex-auto py-0.5 text-xs/5 text-gray-500">
|
<p className="flex-auto py-0.5 text-xs/5 text-text-muted">
|
||||||
<span className="font-semibold text-gray-900">{activityItem.service_name}</span> -{' '}
|
<span className="font-semibold text-text-primary">{activityItem.service_name}</span> -{' '}
|
||||||
{activityItem.type.charAt(0).toUpperCase() + activityItem.type.slice(1)}
|
{activityItem.type.charAt(0).toUpperCase() + activityItem.type.slice(1)}
|
||||||
</p>
|
</p>
|
||||||
<time
|
<time
|
||||||
dateTime={activityItem.timestamp}
|
dateTime={activityItem.timestamp}
|
||||||
className="flex-none py-0.5 text-xs/5 text-gray-500"
|
className="flex-none py-0.5 text-xs/5 text-text-muted"
|
||||||
>
|
>
|
||||||
{activityItem.timestamp}
|
{activityItem.timestamp}
|
||||||
</time>
|
</time>
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,12 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
if (!fullscreen) {
|
if (!fullscreen) {
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col items-center justify-center ${className}`}>
|
<div className="flex flex-col items-center justify-center">
|
||||||
<div
|
<div
|
||||||
className={`w-8 h-8 border-[3px] ${light ? 'border-white' : 'border-slate-400'} border-t-transparent rounded-full animate-spin`}
|
className={`w-8 h-8 border-[3px] ${light ? 'border-white' : 'border-text-muted'} border-t-transparent rounded-full animate-spin ${className || ''}`}
|
||||||
></div>
|
></div>
|
||||||
{!iconOnly && (
|
{!iconOnly && (
|
||||||
<div className={light ? 'text-white mt-2' : 'text-slate-800 mt-2'}>
|
<div className={light ? 'text-white mt-2' : 'text-text-primary mt-2'}>
|
||||||
{text || 'Loading...'}
|
{text || 'Loading...'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,14 @@ const ProgressBar = ({ progress, speed }: { progress: number; speed?: string })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="relative w-full h-2 bg-gray-200 rounded">
|
<div className="relative w-full h-2 bg-border-subtle rounded">
|
||||||
<div
|
<div
|
||||||
className="absolute top-0 left-0 h-full bg-desert-green rounded"
|
className="absolute top-0 left-0 h-full bg-desert-green rounded"
|
||||||
style={{ width: `${progress}%` }}
|
style={{ width: `${progress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{speed && (
|
{speed && (
|
||||||
<div className="mt-1 text-sm text-gray-500">
|
<div className="mt-1 text-sm text-text-muted">
|
||||||
Est. Speed: {speed}
|
Est. Speed: {speed}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ export default function StorageProjectionBar({
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'absolute top-1/2 -translate-y-1/2 font-bold text-sm',
|
'absolute top-1/2 -translate-y-1/2 font-bold text-sm',
|
||||||
projectedTotalPercent > 15
|
projectedTotalPercent > 15
|
||||||
? 'left-3 text-desert-white drop-shadow-md'
|
? 'left-3 text-white drop-shadow-md'
|
||||||
: 'right-3 text-desert-green'
|
: 'right-3 text-desert-green'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,9 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
||||||
switch (variant) {
|
switch (variant) {
|
||||||
case 'primary':
|
case 'primary':
|
||||||
return clsx(
|
return clsx(
|
||||||
'bg-desert-green text-desert-white',
|
'bg-desert-green text-white',
|
||||||
'hover:bg-desert-green-dark hover:shadow-lg',
|
'hover:bg-btn-green-hover hover:shadow-lg',
|
||||||
'active:bg-desert-green-darker',
|
'active:bg-btn-green-active',
|
||||||
'disabled:bg-desert-green-light disabled:text-desert-stone-light',
|
'disabled:bg-desert-green-light disabled:text-desert-stone-light',
|
||||||
baseTransition,
|
baseTransition,
|
||||||
baseHover
|
baseHover
|
||||||
|
|
@ -66,7 +66,7 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
||||||
|
|
||||||
case 'secondary':
|
case 'secondary':
|
||||||
return clsx(
|
return clsx(
|
||||||
'bg-desert-tan text-desert-white',
|
'bg-desert-tan text-white',
|
||||||
'hover:bg-desert-tan-dark hover:shadow-lg',
|
'hover:bg-desert-tan-dark hover:shadow-lg',
|
||||||
'active:bg-desert-tan-dark',
|
'active:bg-desert-tan-dark',
|
||||||
'disabled:bg-desert-tan-lighter disabled:text-desert-stone-light',
|
'disabled:bg-desert-tan-lighter disabled:text-desert-stone-light',
|
||||||
|
|
@ -76,7 +76,7 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
||||||
|
|
||||||
case 'danger':
|
case 'danger':
|
||||||
return clsx(
|
return clsx(
|
||||||
'bg-desert-red text-desert-white',
|
'bg-desert-red text-white',
|
||||||
'hover:bg-desert-red-dark hover:shadow-lg',
|
'hover:bg-desert-red-dark hover:shadow-lg',
|
||||||
'active:bg-desert-red-dark',
|
'active:bg-desert-red-dark',
|
||||||
'disabled:bg-desert-red-lighter disabled:text-desert-stone-light',
|
'disabled:bg-desert-red-lighter disabled:text-desert-stone-light',
|
||||||
|
|
@ -86,7 +86,7 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
||||||
|
|
||||||
case 'action':
|
case 'action':
|
||||||
return clsx(
|
return clsx(
|
||||||
'bg-desert-orange text-desert-white',
|
'bg-desert-orange text-white',
|
||||||
'hover:bg-desert-orange-light hover:shadow-lg',
|
'hover:bg-desert-orange-light hover:shadow-lg',
|
||||||
'active:bg-desert-orange-dark',
|
'active:bg-desert-orange-dark',
|
||||||
'disabled:bg-desert-orange-lighter disabled:text-desert-stone-light',
|
'disabled:bg-desert-orange-lighter disabled:text-desert-stone-light',
|
||||||
|
|
@ -96,7 +96,7 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
||||||
|
|
||||||
case 'success':
|
case 'success':
|
||||||
return clsx(
|
return clsx(
|
||||||
'bg-desert-olive text-desert-white',
|
'bg-desert-olive text-white',
|
||||||
'hover:bg-desert-olive-dark hover:shadow-lg',
|
'hover:bg-desert-olive-dark hover:shadow-lg',
|
||||||
'active:bg-desert-olive-dark',
|
'active:bg-desert-olive-dark',
|
||||||
'disabled:bg-desert-olive-lighter disabled:text-desert-stone-light',
|
'disabled:bg-desert-olive-lighter disabled:text-desert-stone-light',
|
||||||
|
|
@ -116,8 +116,8 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
||||||
case 'outline':
|
case 'outline':
|
||||||
return clsx(
|
return clsx(
|
||||||
'bg-transparent border-2 border-desert-green text-desert-green',
|
'bg-transparent border-2 border-desert-green text-desert-green',
|
||||||
'hover:bg-desert-green hover:text-desert-white hover:border-desert-green-dark',
|
'hover:bg-desert-green hover:text-white hover:border-btn-green-hover',
|
||||||
'active:bg-desert-green-dark active:border-desert-green-darker',
|
'active:bg-btn-green-hover active:border-btn-green-active',
|
||||||
'disabled:border-desert-green-lighter disabled:text-desert-stone-light',
|
'disabled:border-desert-green-lighter disabled:text-desert-stone-light',
|
||||||
baseTransition,
|
baseTransition,
|
||||||
baseHover
|
baseHover
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ const StyledModal: React.FC<StyledModalProps> = ({
|
||||||
>
|
>
|
||||||
<DialogBackdrop
|
<DialogBackdrop
|
||||||
transition
|
transition
|
||||||
className="fixed inset-0 bg-gray-500/75 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in"
|
className="fixed inset-0 bg-black/50 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in"
|
||||||
/>
|
/>
|
||||||
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
|
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
|
||||||
<div
|
<div
|
||||||
|
|
@ -60,14 +60,14 @@ const StyledModal: React.FC<StyledModalProps> = ({
|
||||||
<DialogPanel
|
<DialogPanel
|
||||||
transition
|
transition
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all data-[closed]:translate-y-4 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in sm:my-8 sm:p-6 data-[closed]:sm:translate-y-0 data-[closed]:sm:scale-95',
|
'relative transform overflow-hidden rounded-lg bg-surface-primary px-4 pb-4 pt-5 text-left shadow-xl transition-all data-[closed]:translate-y-4 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in sm:my-8 sm:p-6 data-[closed]:sm:translate-y-0 data-[closed]:sm:scale-95',
|
||||||
large ? 'sm:max-w-7xl !w-full' : 'sm:max-w-lg'
|
large ? 'sm:max-w-7xl !w-full' : 'sm:max-w-lg'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
{icon && <div className="flex items-center justify-center">{icon}</div>}
|
{icon && <div className="flex items-center justify-center">{icon}</div>}
|
||||||
<div className="mt-3 text-center sm:mt-5">
|
<div className="mt-3 text-center sm:mt-5">
|
||||||
<DialogTitle as="h3" className="text-base font-semibold text-gray-900">
|
<DialogTitle as="h3" className="text-base font-semibold text-text-primary">
|
||||||
{title}
|
{title}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<div className="mt-2 !h-fit">{children}</div>
|
<div className="mt-2 !h-fit">{children}</div>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Dialog, DialogBackdrop, DialogPanel, TransitionChild } from '@headlessui/react'
|
import { Dialog, DialogBackdrop, DialogPanel, TransitionChild } from '@headlessui/react'
|
||||||
import classNames from '~/lib/classNames'
|
import classNames from '~/lib/classNames'
|
||||||
import { IconArrowLeft } from '@tabler/icons-react'
|
import { IconArrowLeft, IconBug } from '@tabler/icons-react'
|
||||||
import { usePage } from '@inertiajs/react'
|
import { usePage } from '@inertiajs/react'
|
||||||
import { UsePageProps } from '../../types/system'
|
import { UsePageProps } from '../../types/system'
|
||||||
import { IconMenu2, IconX } from '@tabler/icons-react'
|
import { IconMenu2, IconX } from '@tabler/icons-react'
|
||||||
|
import ThemeToggle from '~/components/ThemeToggle'
|
||||||
|
import DebugInfoModal from './DebugInfoModal'
|
||||||
|
|
||||||
type SidebarItem = {
|
type SidebarItem = {
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -21,6 +23,7 @@ interface StyledSidebarProps {
|
||||||
|
|
||||||
const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
|
const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
|
const [debugModalOpen, setDebugModalOpen] = useState(false)
|
||||||
const { appVersion } = usePage().props as unknown as UsePageProps
|
const { appVersion } = usePage().props as unknown as UsePageProps
|
||||||
|
|
||||||
const currentPath = useMemo(() => {
|
const currentPath = useMemo(() => {
|
||||||
|
|
@ -37,7 +40,7 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
|
||||||
className={classNames(
|
className={classNames(
|
||||||
item.current
|
item.current
|
||||||
? 'bg-desert-green text-white'
|
? 'bg-desert-green text-white'
|
||||||
: 'text-black hover:bg-desert-green-light hover:text-white',
|
: 'text-text-primary hover:bg-desert-green-light hover:text-white',
|
||||||
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold'
|
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -53,7 +56,7 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
|
||||||
<div className="flex grow flex-col gap-y-5 overflow-y-auto bg-desert-sand px-6 ring-1 ring-white/5 pt-4 shadow-md">
|
<div className="flex grow flex-col gap-y-5 overflow-y-auto bg-desert-sand px-6 ring-1 ring-white/5 pt-4 shadow-md">
|
||||||
<div className="flex h-16 shrink-0 items-center">
|
<div className="flex h-16 shrink-0 items-center">
|
||||||
<img src="/project_nomad_logo.png" alt="Project Nomad Logo" className="h-16 w-16" />
|
<img src="/project_nomad_logo.png" alt="Project Nomad Logo" className="h-16 w-16" />
|
||||||
<h1 className="ml-3 text-xl font-semibold text-black">{title}</h1>
|
<h1 className="ml-3 text-xl font-semibold text-text-primary">{title}</h1>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex flex-1 flex-col">
|
<nav className="flex flex-1 flex-col">
|
||||||
<ul role="list" className="flex flex-1 flex-col gap-y-7">
|
<ul role="list" className="flex flex-1 flex-col gap-y-7">
|
||||||
|
|
@ -75,8 +78,16 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="mb-4 text-center text-sm text-gray-600">
|
<div className="mb-4 flex flex-col items-center gap-1 text-sm text-text-secondary text-center">
|
||||||
<p>Project N.O.M.A.D. Command Center v{appVersion}</p>
|
<p>Project N.O.M.A.D. Command Center v{appVersion}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setDebugModalOpen(true)}
|
||||||
|
className="text-gray-500 hover:text-desert-green inline-flex items-center gap-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
<IconBug className="size-3.5" />
|
||||||
|
Debug Info
|
||||||
|
</button>
|
||||||
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -123,6 +134,7 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
|
||||||
<div className="hidden xl:fixed xl:inset-y-0 xl:z-50 xl:flex xl:w-72 xl:flex-col">
|
<div className="hidden xl:fixed xl:inset-y-0 xl:z-50 xl:flex xl:w-72 xl:flex-col">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
</div>
|
</div>
|
||||||
|
<DebugInfoModal open={debugModalOpen} onClose={() => setDebugModalOpen(false)} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,19 +74,19 @@ function StyledTable<T extends { [key: string]: any }>({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'w-full overflow-x-auto bg-white ring-1 ring-gray-300 sm:mx-0 sm:rounded-lg p-1 shadow-md',
|
'w-full overflow-x-auto bg-surface-primary ring-1 ring-border-default sm:mx-0 sm:rounded-lg p-1 shadow-md',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...containerProps}
|
{...containerProps}
|
||||||
>
|
>
|
||||||
<table className="min-w-full overflow-auto" {...restTableProps}>
|
<table className="min-w-full overflow-auto" {...restTableProps}>
|
||||||
<thead className='border-b border-gray-200 '>
|
<thead className='border-b border-border-subtle '>
|
||||||
<tr>
|
<tr>
|
||||||
{expandable && (
|
{expandable && (
|
||||||
<th
|
<th
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'whitespace-nowrap text-left font-semibold text-gray-900 w-12',
|
'whitespace-nowrap text-left font-semibold text-text-primary w-12',
|
||||||
compact ? `${leftPadding} py-2` : `${leftPadding} py-4 pr-3`
|
compact ? `${leftPadding} py-2` : `${leftPadding} py-4 pr-3`
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -95,7 +95,7 @@ function StyledTable<T extends { [key: string]: any }>({
|
||||||
<th
|
<th
|
||||||
key={index}
|
key={index}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'whitespace-nowrap text-left font-semibold text-gray-900',
|
'whitespace-nowrap text-left font-semibold text-text-primary',
|
||||||
compact ? `${leftPadding} py-2` : `${leftPadding} py-4 pr-3`
|
compact ? `${leftPadding} py-2` : `${leftPadding} py-4 pr-3`
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -121,8 +121,8 @@ function StyledTable<T extends { [key: string]: any }>({
|
||||||
'translateY' in record ? 'translateY(' + record.transformY + 'px)' : undefined,
|
'translateY' in record ? 'translateY(' + record.transformY + 'px)' : undefined,
|
||||||
}}
|
}}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
rowLines ? 'border-b border-gray-200' : '',
|
rowLines ? 'border-b border-border-subtle' : '',
|
||||||
onRowClick ? `cursor-pointer hover:bg-gray-100 ` : ''
|
onRowClick ? `cursor-pointer hover:bg-surface-secondary ` : ''
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{expandable && (
|
{expandable && (
|
||||||
|
|
@ -134,7 +134,7 @@ function StyledTable<T extends { [key: string]: any }>({
|
||||||
onClick={(e) => toggleRowExpansion(record, recordIdx, e)}
|
onClick={(e) => toggleRowExpansion(record, recordIdx, e)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="text-gray-500 hover:text-gray-700 focus:outline-none"
|
className="text-text-muted hover:text-text-primary focus:outline-none"
|
||||||
aria-label={isExpanded ? 'Collapse row' : 'Expand row'}
|
aria-label={isExpanded ? 'Collapse row' : 'Expand row'}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -172,7 +172,7 @@ function StyledTable<T extends { [key: string]: any }>({
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
{expandable && isExpanded && (
|
{expandable && isExpanded && (
|
||||||
<tr className="bg-gray-50">
|
<tr className="bg-surface-secondary">
|
||||||
<td colSpan={columns.length + 1}>
|
<td colSpan={columns.length + 1}>
|
||||||
{expandable.expandedRowRender(record, recordIdx)}
|
{expandable.expandedRowRender(record, recordIdx)}
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -183,7 +183,7 @@ function StyledTable<T extends { [key: string]: any }>({
|
||||||
})}
|
})}
|
||||||
{!loading && data.length === 0 && (
|
{!loading && data.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={columns.length + (expandable ? 1 : 0)} className="!text-center py-8 text-gray-500">
|
<td colSpan={columns.length + (expandable ? 1 : 0)} className="!text-center py-8 text-text-muted">
|
||||||
{noDataText}
|
{noDataText}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
24
admin/inertia/components/ThemeToggle.tsx
Normal file
24
admin/inertia/components/ThemeToggle.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { IconSun, IconMoon } from '@tabler/icons-react'
|
||||||
|
import { useThemeContext } from '~/providers/ThemeProvider'
|
||||||
|
|
||||||
|
interface ThemeToggleProps {
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ThemeToggle({ compact = false }: ThemeToggleProps) {
|
||||||
|
const { theme, toggleTheme } = useThemeContext()
|
||||||
|
const isDark = theme === 'dark'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm transition-colors
|
||||||
|
text-desert-stone hover:text-desert-green-darker cursor-pointer"
|
||||||
|
aria-label={isDark ? 'Switch to Day Ops' : 'Switch to Night Ops'}
|
||||||
|
title={isDark ? 'Switch to Day Ops' : 'Switch to Night Ops'}
|
||||||
|
>
|
||||||
|
{isDark ? <IconSun className="size-4" /> : <IconMoon className="size-4" />}
|
||||||
|
{!compact && <span>{isDark ? 'Day Ops' : 'Night Ops'}</span>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import { resolveTierResources } from '~/lib/collections'
|
||||||
import { formatBytes } from '~/lib/util'
|
import { formatBytes } from '~/lib/util'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import DynamicIcon, { DynamicIconName } from './DynamicIcon'
|
import DynamicIcon, { DynamicIconName } from './DynamicIcon'
|
||||||
|
import StyledButton from './StyledButton'
|
||||||
|
|
||||||
interface TierSelectionModalProps {
|
interface TierSelectionModalProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
|
|
@ -88,7 +89,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
||||||
leaveFrom="opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="opacity-0 scale-95"
|
leaveTo="opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<Dialog.Panel className="w-full max-w-4xl transform overflow-hidden rounded-lg bg-white shadow-xl transition-all">
|
<Dialog.Panel className="w-full max-w-4xl transform overflow-hidden rounded-lg bg-surface-primary shadow-xl transition-all">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-desert-green px-6 py-4">
|
<div className="bg-desert-green px-6 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -101,7 +102,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
||||||
<Dialog.Title className="text-xl font-semibold text-white">
|
<Dialog.Title className="text-xl font-semibold text-white">
|
||||||
{category.name}
|
{category.name}
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<p className="text-sm text-gray-200">{category.description}</p>
|
<p className="text-sm text-text-muted">{category.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -115,7 +116,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<p className="text-gray-600 mb-6">
|
<p className="text-text-secondary mb-6">
|
||||||
Select a tier based on your storage capacity and needs. Higher tiers include all content from lower tiers.
|
Select a tier based on your storage capacity and needs. Higher tiers include all content from lower tiers.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -138,30 +139,30 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
||||||
'border-2 rounded-lg p-5 cursor-pointer transition-all',
|
'border-2 rounded-lg p-5 cursor-pointer transition-all',
|
||||||
isSelected
|
isSelected
|
||||||
? 'border-desert-green bg-desert-green/5 shadow-md'
|
? 'border-desert-green bg-desert-green/5 shadow-md'
|
||||||
: 'border-gray-200 hover:border-desert-green/50 hover:shadow-sm'
|
: 'border-border-subtle hover:border-desert-green/50 hover:shadow-sm'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<h3 className="text-lg font-semibold text-gray-900">
|
<h3 className="text-lg font-semibold text-text-primary">
|
||||||
{tier.name}
|
{tier.name}
|
||||||
</h3>
|
</h3>
|
||||||
{includedTierName && (
|
{includedTierName && (
|
||||||
<span className="text-xs text-gray-500">
|
<span className="text-xs text-text-muted">
|
||||||
(includes {includedTierName})
|
(includes {includedTierName})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-600 text-sm mb-3">{tier.description}</p>
|
<p className="text-text-secondary text-sm mb-3">{tier.description}</p>
|
||||||
|
|
||||||
{/* Resources preview - only show this tier's own resources */}
|
{/* Resources preview - only show this tier's own resources */}
|
||||||
<div className="bg-gray-50 rounded p-3">
|
<div className="bg-surface-secondary rounded p-3">
|
||||||
<p className="text-xs text-gray-500 mb-2 font-medium">
|
<p className="text-xs text-text-muted mb-2 font-medium">
|
||||||
{includedTierName ? (
|
{includedTierName ? (
|
||||||
<>
|
<>
|
||||||
{ownResourceCount} additional {ownResourceCount === 1 ? 'resource' : 'resources'}
|
{ownResourceCount} additional {ownResourceCount === 1 ? 'resource' : 'resources'}
|
||||||
<span className="text-gray-400"> (plus everything in {includedTierName})</span>
|
<span className="text-text-muted"> (plus everything in {includedTierName})</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>{ownResourceCount} {ownResourceCount === 1 ? 'resource' : 'resources'} included</>
|
<>{ownResourceCount} {ownResourceCount === 1 ? 'resource' : 'resources'} included</>
|
||||||
|
|
@ -172,8 +173,8 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
||||||
<div key={idx} className="flex items-start text-sm">
|
<div key={idx} className="flex items-start text-sm">
|
||||||
<IconCheck size={14} className="text-desert-green mr-1.5 mt-0.5 flex-shrink-0" />
|
<IconCheck size={14} className="text-desert-green mr-1.5 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-700">{resource.title}</span>
|
<span className="text-text-primary">{resource.title}</span>
|
||||||
<span className="text-gray-400 text-xs ml-1">
|
<span className="text-text-muted text-xs ml-1">
|
||||||
({formatBytes(resource.size_mb * 1024 * 1024, 0)})
|
({formatBytes(resource.size_mb * 1024 * 1024, 0)})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -184,14 +185,14 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-4 text-right flex-shrink-0">
|
<div className="ml-4 text-right flex-shrink-0">
|
||||||
<div className="text-lg font-semibold text-gray-900">
|
<div className="text-lg font-semibold text-text-primary">
|
||||||
{formatBytes(totalSize, 1)}
|
{formatBytes(totalSize, 1)}
|
||||||
</div>
|
</div>
|
||||||
<div className={classNames(
|
<div className={classNames(
|
||||||
'w-6 h-6 rounded-full border-2 flex items-center justify-center mt-2 ml-auto',
|
'w-6 h-6 rounded-full border-2 flex items-center justify-center mt-2 ml-auto',
|
||||||
isSelected
|
isSelected
|
||||||
? 'border-desert-green bg-desert-green'
|
? 'border-desert-green bg-desert-green'
|
||||||
: 'border-gray-300'
|
: 'border-border-default'
|
||||||
)}>
|
)}>
|
||||||
{isSelected && <IconCheck size={16} className="text-white" />}
|
{isSelected && <IconCheck size={16} className="text-white" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -203,7 +204,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info note */}
|
{/* Info note */}
|
||||||
<div className="mt-6 flex items-start gap-2 text-sm text-gray-500 bg-blue-50 p-3 rounded">
|
<div className="mt-6 flex items-start gap-2 text-sm text-text-muted bg-blue-50 p-3 rounded">
|
||||||
<IconInfoCircle size={18} className="text-blue-500 flex-shrink-0 mt-0.5" />
|
<IconInfoCircle size={18} className="text-blue-500 flex-shrink-0 mt-0.5" />
|
||||||
<p>
|
<p>
|
||||||
You can change your selection at any time. Click Submit to confirm your choice.
|
You can change your selection at any time. Click Submit to confirm your choice.
|
||||||
|
|
@ -212,19 +213,15 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="bg-gray-50 px-6 py-4 flex justify-end gap-3">
|
<div className="bg-surface-secondary px-6 py-4 flex justify-end gap-3">
|
||||||
<button
|
<StyledButton
|
||||||
|
variant='primary'
|
||||||
|
size='lg'
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!localSelectedSlug}
|
disabled={!localSelectedSlug}
|
||||||
className={classNames(
|
|
||||||
'px-4 py-2 rounded-md font-medium transition-colors',
|
|
||||||
localSelectedSlug
|
|
||||||
? 'bg-desert-green text-white hover:bg-desert-green/90'
|
|
||||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
Submit
|
Submit
|
||||||
</button>
|
</StyledButton>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
|
|
|
||||||
|
|
@ -60,12 +60,12 @@ export default function UpdateServiceModal({
|
||||||
icon={<IconArrowUp className="h-12 w-12 text-desert-green" />}
|
icon={<IconArrowUp className="h-12 w-12 text-desert-green" />}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-gray-700">
|
<p className="text-text-primary">
|
||||||
Update <strong>{record.friendly_name || record.service_name}</strong> from{' '}
|
Update <strong>{record.friendly_name || record.service_name}</strong> from{' '}
|
||||||
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-sm">{currentTag}</code> to{' '}
|
<code className="bg-surface-secondary px-1.5 py-0.5 rounded text-sm">{currentTag}</code> to{' '}
|
||||||
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-sm">{selectedVersion}</code>?
|
<code className="bg-surface-secondary px-1.5 py-0.5 rounded text-sm">{selectedVersion}</code>?
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-text-muted">
|
||||||
Your data and configuration will be preserved during the update.
|
Your data and configuration will be preserved during the update.
|
||||||
{versions.find((v) => v.tag === selectedVersion)?.releaseUrl && (
|
{versions.find((v) => v.tag === selectedVersion)?.releaseUrl && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -95,14 +95,14 @@ export default function UpdateServiceModal({
|
||||||
<>
|
<>
|
||||||
<div className="mt-3 max-h-48 overflow-y-auto border rounded-lg divide-y">
|
<div className="mt-3 max-h-48 overflow-y-auto border rounded-lg divide-y">
|
||||||
{loadingVersions ? (
|
{loadingVersions ? (
|
||||||
<div className="p-4 text-center text-gray-500 text-sm">Loading versions...</div>
|
<div className="p-4 text-center text-text-muted text-sm">Loading versions...</div>
|
||||||
) : versions.length === 0 ? (
|
) : versions.length === 0 ? (
|
||||||
<div className="p-4 text-center text-gray-500 text-sm">No other versions available</div>
|
<div className="p-4 text-center text-text-muted text-sm">No other versions available</div>
|
||||||
) : (
|
) : (
|
||||||
versions.map((v) => (
|
versions.map((v) => (
|
||||||
<label
|
<label
|
||||||
key={v.tag}
|
key={v.tag}
|
||||||
className="flex items-center gap-3 px-4 py-2.5 hover:bg-gray-50 cursor-pointer"
|
className="flex items-center gap-3 px-4 py-2.5 hover:bg-surface-secondary cursor-pointer"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
|
|
@ -112,7 +112,7 @@ export default function UpdateServiceModal({
|
||||||
onChange={() => setSelectedVersion(v.tag)}
|
onChange={() => setSelectedVersion(v.tag)}
|
||||||
className="text-desert-green focus:ring-desert-green"
|
className="text-desert-green focus:ring-desert-green"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium text-gray-900">{v.tag}</span>
|
<span className="text-sm font-medium text-text-primary">{v.tag}</span>
|
||||||
{v.isLatest && (
|
{v.isLatest && (
|
||||||
<span className="text-xs bg-desert-green/10 text-desert-green px-2 py-0.5 rounded-full">
|
<span className="text-xs bg-desert-green/10 text-desert-green px-2 py-0.5 rounded-full">
|
||||||
Latest
|
Latest
|
||||||
|
|
@ -133,7 +133,7 @@ export default function UpdateServiceModal({
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-sm text-gray-500">
|
<p className="mt-2 text-sm text-text-muted">
|
||||||
It's not recommended to upgrade to a new major version (e.g. 1.8.2 → 2.0.0) unless you have verified compatibility with your current configuration. Always review the release notes and test in a staging environment if possible.
|
It's not recommended to upgrade to a new major version (e.g. 1.8.2 → 2.0.0) unless you have verified compatibility with your current configuration. Always review the release notes and test in a staging environment if possible.
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { formatBytes } from '~/lib/util'
|
import { formatBytes } from '~/lib/util'
|
||||||
import { WikipediaOption, WikipediaCurrentSelection } from '../../types/downloads'
|
import { WikipediaOption, WikipediaCurrentSelection } from '../../types/downloads'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import { IconCheck, IconDownload, IconWorld } from '@tabler/icons-react'
|
import { IconCheck, IconDownload, IconWorld, IconAlertTriangle } from '@tabler/icons-react'
|
||||||
import StyledButton from './StyledButton'
|
import StyledButton from './StyledButton'
|
||||||
import LoadingSpinner from './LoadingSpinner'
|
import LoadingSpinner from './LoadingSpinner'
|
||||||
|
|
||||||
|
|
@ -29,32 +29,45 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
|
||||||
// Determine which option to highlight
|
// Determine which option to highlight
|
||||||
const highlightedOptionId = selectedOptionId ?? currentSelection?.optionId ?? null
|
const highlightedOptionId = selectedOptionId ?? currentSelection?.optionId ?? null
|
||||||
|
|
||||||
// Check if current selection is downloading
|
// Check if current selection is downloading or failed
|
||||||
const isDownloading = currentSelection?.status === 'downloading'
|
const isDownloading = currentSelection?.status === 'downloading'
|
||||||
|
const isFailed = currentSelection?.status === 'failed'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{/* Header with Wikipedia branding */}
|
{/* Header with Wikipedia branding */}
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<div className="w-10 h-10 rounded-full bg-white flex items-center justify-center shadow-sm">
|
<div className="w-10 h-10 rounded-full bg-white flex items-center justify-center shadow-sm">
|
||||||
<IconWorld className="w-6 h-6 text-gray-700" />
|
<IconWorld className="w-6 h-6 text-text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-semibold text-gray-900">Wikipedia</h3>
|
<h3 className="text-xl font-semibold text-text-primary">Wikipedia</h3>
|
||||||
<p className="text-sm text-gray-500">Select your preferred Wikipedia package</p>
|
<p className="text-sm text-text-muted">Select your preferred Wikipedia package</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Downloading status message */}
|
{/* Downloading status message */}
|
||||||
{isDownloading && (
|
{isDownloading && (
|
||||||
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-center gap-2">
|
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-center gap-2">
|
||||||
<LoadingSpinner fullscreen={false} iconOnly className="size-5" />
|
<LoadingSpinner fullscreen={false} iconOnly className="size-4" />
|
||||||
<span className="text-sm text-blue-700">
|
<span className="text-sm text-blue-700">
|
||||||
Downloading Wikipedia... This may take a while for larger packages.
|
Downloading Wikipedia... This may take a while for larger packages.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Failed status message */}
|
||||||
|
{isFailed && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<IconAlertTriangle className="w-5 h-5 text-red-600 flex-shrink-0" />
|
||||||
|
<span className="text-sm text-red-700">
|
||||||
|
Wikipedia download failed. Select a package and try again.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Options grid */}
|
{/* Options grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
|
|
@ -63,6 +76,8 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
|
||||||
currentSelection?.optionId === option.id && currentSelection?.status === 'installed'
|
currentSelection?.optionId === option.id && currentSelection?.status === 'installed'
|
||||||
const isCurrentDownloading =
|
const isCurrentDownloading =
|
||||||
currentSelection?.optionId === option.id && currentSelection?.status === 'downloading'
|
currentSelection?.optionId === option.id && currentSelection?.status === 'downloading'
|
||||||
|
const isCurrentFailed =
|
||||||
|
currentSelection?.optionId === option.id && currentSelection?.status === 'failed'
|
||||||
const isPending = selectedOptionId === option.id && selectedOptionId !== currentSelection?.optionId
|
const isPending = selectedOptionId === option.id && selectedOptionId !== currentSelection?.optionId
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -78,7 +93,7 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
|
||||||
? 'border-desert-green bg-desert-green/10'
|
? 'border-desert-green bg-desert-green/10'
|
||||||
: isSelected
|
: isSelected
|
||||||
? 'border-lime-500 bg-lime-50'
|
? 'border-lime-500 bg-lime-50'
|
||||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
: 'border-border-subtle bg-surface-primary hover:border-border-default'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Status badges */}
|
{/* Status badges */}
|
||||||
|
|
@ -100,12 +115,18 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
|
||||||
Downloading
|
Downloading
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{isCurrentFailed && (
|
||||||
|
<span className="text-xs bg-red-500 text-white px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||||
|
<IconAlertTriangle size={12} />
|
||||||
|
Failed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Option content */}
|
{/* Option content */}
|
||||||
<div className="pr-16 flex flex-col h-full">
|
<div className="pr-16 flex flex-col h-full">
|
||||||
<h4 className="text-lg font-semibold text-gray-900 mb-1">{option.name}</h4>
|
<h4 className="text-lg font-semibold text-text-primary mb-1">{option.name}</h4>
|
||||||
<p className="text-sm text-gray-600 mb-3 flex-grow">{option.description}</p>
|
<p className="text-sm text-text-secondary mb-3 flex-grow">{option.description}</p>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Radio indicator */}
|
{/* Radio indicator */}
|
||||||
<div
|
<div
|
||||||
|
|
@ -115,7 +136,7 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
|
||||||
? isInstalled
|
? isInstalled
|
||||||
? 'border-desert-green bg-desert-green'
|
? 'border-desert-green bg-desert-green'
|
||||||
: 'border-lime-500 bg-lime-500'
|
: 'border-lime-500 bg-lime-500'
|
||||||
: 'border-gray-300'
|
: 'border-border-default'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isSelected && <IconCheck size={12} className="text-white" />}
|
{isSelected && <IconCheck size={12} className="text-white" />}
|
||||||
|
|
@ -123,7 +144,7 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
|
||||||
<span
|
<span
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'text-sm font-medium px-2 py-1 rounded',
|
'text-sm font-medium px-2 py-1 rounded',
|
||||||
option.size_mb === 0 ? 'bg-gray-100 text-gray-500' : 'bg-gray-100 text-gray-700'
|
option.size_mb === 0 ? 'bg-surface-secondary text-text-muted' : 'bg-surface-secondary text-text-secondary'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{option.size_mb === 0 ? 'No download' : formatBytes(option.size_mb * 1024 * 1024, 1)}
|
{option.size_mb === 0 ? 'No download' : formatBytes(option.size_mb * 1024 * 1024, 1)}
|
||||||
|
|
@ -136,7 +157,7 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Submit button for Content Explorer mode */}
|
{/* Submit button for Content Explorer mode */}
|
||||||
{showSubmitButton && selectedOptionId && selectedOptionId !== currentSelection?.optionId && (
|
{showSubmitButton && selectedOptionId && (selectedOptionId !== currentSelection?.optionId || isFailed) && (
|
||||||
<div className="mt-4 flex justify-end">
|
<div className="mt-4 flex justify-end">
|
||||||
<StyledButton
|
<StyledButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
|
|
||||||
|
|
@ -85,19 +85,19 @@ export default function ChatInterface({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col min-h-0 bg-white shadow-sm">
|
<div className="flex-1 flex flex-col min-h-0 bg-surface-primary shadow-sm">
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
<div className="h-full flex items-center justify-center">
|
<div className="h-full flex items-center justify-center">
|
||||||
<div className="text-center max-w-md">
|
<div className="text-center max-w-md">
|
||||||
<IconWand className="h-16 w-16 text-desert-green mx-auto mb-4 opacity-50" />
|
<IconWand className="h-16 w-16 text-desert-green mx-auto mb-4 opacity-50" />
|
||||||
<h3 className="text-lg font-medium text-gray-700 mb-2">Start a conversation</h3>
|
<h3 className="text-lg font-medium text-text-primary mb-2">Start a conversation</h3>
|
||||||
<p className="text-gray-500 text-sm">
|
<p className="text-text-muted text-sm">
|
||||||
Interact with your installed language models directly in the Command Center.
|
Interact with your installed language models directly in the Command Center.
|
||||||
</p>
|
</p>
|
||||||
{chatSuggestionsEnabled && chatSuggestions && chatSuggestions.length > 0 && !chatSuggestionsLoading && (
|
{chatSuggestionsEnabled && chatSuggestions && chatSuggestions.length > 0 && !chatSuggestionsLoading && (
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h4 className="text-sm font-medium text-gray-600 mb-2">Suggestions:</h4>
|
<h4 className="text-sm font-medium text-text-secondary mb-2">Suggestions:</h4>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{chatSuggestions.map((suggestion, index) => (
|
{chatSuggestions.map((suggestion, index) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -109,7 +109,7 @@ export default function ChatInterface({
|
||||||
textareaRef.current?.focus()
|
textareaRef.current?.focus()
|
||||||
}, 0)
|
}, 0)
|
||||||
}}
|
}}
|
||||||
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm text-gray-700 transition-colors"
|
className="px-4 py-2 bg-surface-secondary hover:bg-surface-secondary rounded-lg text-sm text-text-primary transition-colors"
|
||||||
>
|
>
|
||||||
{suggestion}
|
{suggestion}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -120,7 +120,7 @@ export default function ChatInterface({
|
||||||
{/* Display bouncing dots while loading suggestions */}
|
{/* Display bouncing dots while loading suggestions */}
|
||||||
{chatSuggestionsEnabled && chatSuggestionsLoading && <BouncingDots text="Thinking" containerClassName="mt-8" />}
|
{chatSuggestionsEnabled && chatSuggestionsLoading && <BouncingDots text="Thinking" containerClassName="mt-8" />}
|
||||||
{!chatSuggestionsEnabled && (
|
{!chatSuggestionsEnabled && (
|
||||||
<div className="mt-8 text-sm text-gray-500">
|
<div className="mt-8 text-sm text-text-muted">
|
||||||
Need some inspiration? Enable chat suggestions in settings to get started with example prompts.
|
Need some inspiration? Enable chat suggestions in settings to get started with example prompts.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -144,7 +144,7 @@ export default function ChatInterface({
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="flex gap-4 justify-start">
|
<div className="flex gap-4 justify-start">
|
||||||
<ChatAssistantAvatar />
|
<ChatAssistantAvatar />
|
||||||
<div className="max-w-[70%] rounded-lg px-4 py-3 bg-gray-100 text-gray-800">
|
<div className="max-w-[70%] rounded-lg px-4 py-3 bg-surface-secondary text-text-primary">
|
||||||
<BouncingDots text="Thinking" />
|
<BouncingDots text="Thinking" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -154,7 +154,7 @@ export default function ChatInterface({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-gray-200 bg-white px-6 py-4 flex-shrink-0 min-h-[90px]">
|
<div className="border-t border-border-subtle bg-surface-primary px-6 py-4 flex-shrink-0 min-h-[90px]">
|
||||||
<form onSubmit={handleSubmit} className="flex gap-3 items-end">
|
<form onSubmit={handleSubmit} className="flex gap-3 items-end">
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<textarea
|
<textarea
|
||||||
|
|
@ -163,7 +163,7 @@ export default function ChatInterface({
|
||||||
onChange={handleInput}
|
onChange={handleInput}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={`Type your message to ${aiAssistantName}... (Shift+Enter for new line)`}
|
placeholder={`Type your message to ${aiAssistantName}... (Shift+Enter for new line)`}
|
||||||
className="w-full resize-none rounded-lg border border-gray-300 px-4 py-3 pr-12 focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent disabled:bg-gray-50 disabled:text-gray-500"
|
className="w-full resize-none rounded-lg border border-border-default px-4 py-3 pr-12 focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent disabled:bg-surface-secondary disabled:text-text-muted"
|
||||||
rows={1}
|
rows={1}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
style={{ maxHeight: '200px' }}
|
style={{ maxHeight: '200px' }}
|
||||||
|
|
@ -175,7 +175,7 @@ export default function ChatInterface({
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'p-3 rounded-lg transition-all duration-200 flex-shrink-0 mb-2',
|
'p-3 rounded-lg transition-all duration-200 flex-shrink-0 mb-2',
|
||||||
!input.trim() || isLoading
|
!input.trim() || isLoading
|
||||||
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
? 'bg-border-default text-text-muted cursor-not-allowed'
|
||||||
: 'bg-desert-green text-white hover:bg-desert-green/90 hover:scale-105'
|
: 'bg-desert-green text-white hover:bg-desert-green/90 hover:scale-105'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -187,7 +187,7 @@ export default function ChatInterface({
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{!rewriteModelAvailable && (
|
{!rewriteModelAvailable && (
|
||||||
<div className="text-sm text-gray-500 mt-2">
|
<div className="text-sm text-text-muted mt-2">
|
||||||
The {DEFAULT_QUERY_REWRITE_MODEL} model is not installed. Consider{' '}
|
The {DEFAULT_QUERY_REWRITE_MODEL} model is not installed. Consider{' '}
|
||||||
<button
|
<button
|
||||||
onClick={() => setDownloadDialogOpen(true)}
|
onClick={() => setDownloadDialogOpen(true)}
|
||||||
|
|
@ -210,7 +210,7 @@ export default function ChatInterface({
|
||||||
onCancel={() => setDownloadDialogOpen(false)}
|
onCancel={() => setDownloadDialogOpen(false)}
|
||||||
onClose={() => setDownloadDialogOpen(false)}
|
onClose={() => setDownloadDialogOpen(false)}
|
||||||
>
|
>
|
||||||
<p className="text-gray-700">
|
<p className="text-text-primary">
|
||||||
This will dispatch a background download job for{' '}
|
This will dispatch a background download job for{' '}
|
||||||
<span className="font-mono font-medium">{DEFAULT_QUERY_REWRITE_MODEL}</span> and may take some time to complete. The model
|
<span className="font-mono font-medium">{DEFAULT_QUERY_REWRITE_MODEL}</span> and may take some time to complete. The model
|
||||||
will be used to rewrite queries for improved RAG retrieval performance.
|
will be used to rewrite queries for improved RAG retrieval performance.
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export default function ChatMessageBubble({ message }: ChatMessageBubbleProps) {
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'max-w-[70%] rounded-lg px-4 py-3',
|
'max-w-[70%] rounded-lg px-4 py-3',
|
||||||
message.role === 'user' ? 'bg-desert-green text-white' : 'bg-gray-100 text-gray-800'
|
message.role === 'user' ? 'bg-desert-green text-white' : 'bg-surface-secondary text-text-primary'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{message.isThinking && message.thinking && (
|
{message.isThinking && message.thinking && (
|
||||||
|
|
@ -27,13 +27,13 @@ export default function ChatMessageBubble({ message }: ChatMessageBubbleProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!message.isThinking && message.thinking && (
|
{!message.isThinking && message.thinking && (
|
||||||
<details className="mb-3 rounded border border-gray-200 bg-gray-50 text-xs">
|
<details className="mb-3 rounded border border-border-subtle bg-surface-secondary text-xs">
|
||||||
<summary className="cursor-pointer px-3 py-2 font-medium text-gray-500 hover:text-gray-700 select-none">
|
<summary className="cursor-pointer px-3 py-2 font-medium text-text-muted hover:text-text-primary select-none">
|
||||||
{message.thinkingDuration !== undefined
|
{message.thinkingDuration !== undefined
|
||||||
? `Thought for ${message.thinkingDuration}s`
|
? `Thought for ${message.thinkingDuration}s`
|
||||||
: 'Reasoning'}
|
: 'Reasoning'}
|
||||||
</summary>
|
</summary>
|
||||||
<div className="px-3 pb-3 prose prose-xs max-w-none text-gray-600 max-h-48 overflow-y-auto border-t border-gray-200 pt-2">
|
<div className="px-3 pb-3 prose prose-xs max-w-none text-text-secondary max-h-48 overflow-y-auto border-t border-border-subtle pt-2">
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{message.thinking}</ReactMarkdown>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{message.thinking}</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
@ -77,7 +77,7 @@ export default function ChatMessageBubble({ message }: ChatMessageBubbleProps) {
|
||||||
h2: ({ children }) => <h2 className="text-lg font-bold mb-2">{children}</h2>,
|
h2: ({ children }) => <h2 className="text-lg font-bold mb-2">{children}</h2>,
|
||||||
h3: ({ children }) => <h3 className="text-base font-bold mb-2">{children}</h3>,
|
h3: ({ children }) => <h3 className="text-base font-bold mb-2">{children}</h3>,
|
||||||
blockquote: ({ children }) => (
|
blockquote: ({ children }) => (
|
||||||
<blockquote className="border-l-4 border-gray-400 pl-4 italic my-2">
|
<blockquote className="border-l-4 border-border-default pl-4 italic my-2">
|
||||||
{children}
|
{children}
|
||||||
</blockquote>
|
</blockquote>
|
||||||
),
|
),
|
||||||
|
|
@ -105,7 +105,7 @@ export default function ChatMessageBubble({ message }: ChatMessageBubbleProps) {
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'text-xs mt-2',
|
'text-xs mt-2',
|
||||||
message.role === 'user' ? 'text-white/70' : 'text-gray-500'
|
message.role === 'user' ? 'text-white/70' : 'text-text-muted'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{message.timestamp.toLocaleTimeString([], {
|
{message.timestamp.toLocaleTimeString([], {
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export default function ChatModal({ open, onClose }: ChatModalProps) {
|
||||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||||
<DialogPanel
|
<DialogPanel
|
||||||
transition
|
transition
|
||||||
className="relative bg-white rounded-xl shadow-2xl w-full max-w-7xl h-[85vh] flex overflow-hidden transition-all data-[closed]:scale-95 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in"
|
className="relative bg-surface-primary rounded-xl shadow-2xl w-full max-w-7xl h-[85vh] flex overflow-hidden transition-all data-[closed]:scale-95 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in"
|
||||||
>
|
>
|
||||||
<Chat enabled={open} isInModal onClose={onClose} suggestionsEnabled={parseBoolean(settings.data?.value)} />
|
<Chat enabled={open} isInModal onClose={onClose} suggestionsEnabled={parseBoolean(settings.data?.value)} />
|
||||||
</DialogPanel>
|
</DialogPanel>
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,8 @@ export default function ChatSidebar({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-64 bg-gray-50 border-r border-gray-200 flex flex-col h-full">
|
<div className="w-64 bg-surface-secondary border-r border-border-subtle flex flex-col h-full">
|
||||||
<div className="p-4 border-b border-gray-200 h-[75px] flex items-center justify-center">
|
<div className="p-4 border-b border-border-subtle h-[75px] flex items-center justify-center">
|
||||||
<StyledButton onClick={onNewChat} icon="IconPlus" variant="primary" fullWidth>
|
<StyledButton onClick={onNewChat} icon="IconPlus" variant="primary" fullWidth>
|
||||||
New Chat
|
New Chat
|
||||||
</StyledButton>
|
</StyledButton>
|
||||||
|
|
@ -48,7 +48,7 @@ export default function ChatSidebar({
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{sessions.length === 0 ? (
|
{sessions.length === 0 ? (
|
||||||
<div className="p-4 text-center text-gray-500 text-sm">No previous chats</div>
|
<div className="p-4 text-center text-text-muted text-sm">No previous chats</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-2 space-y-1">
|
<div className="p-2 space-y-1">
|
||||||
{sessions.map((session) => (
|
{sessions.map((session) => (
|
||||||
|
|
@ -59,14 +59,14 @@ export default function ChatSidebar({
|
||||||
'w-full text-left px-3 py-2 rounded-lg transition-colors group',
|
'w-full text-left px-3 py-2 rounded-lg transition-colors group',
|
||||||
activeSessionId === session.id
|
activeSessionId === session.id
|
||||||
? 'bg-desert-green text-white'
|
? 'bg-desert-green text-white'
|
||||||
: 'hover:bg-gray-200 text-gray-700'
|
: 'hover:bg-surface-secondary text-text-primary'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<IconMessage
|
<IconMessage
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'h-5 w-5 mt-0.5 shrink-0',
|
'h-5 w-5 mt-0.5 shrink-0',
|
||||||
activeSessionId === session.id ? 'text-white' : 'text-gray-400'
|
activeSessionId === session.id ? 'text-white' : 'text-text-muted'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|
@ -75,7 +75,7 @@ export default function ChatSidebar({
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'text-xs truncate mt-0.5',
|
'text-xs truncate mt-0.5',
|
||||||
activeSessionId === session.id ? 'text-white/80' : 'text-gray-500'
|
activeSessionId === session.id ? 'text-white/80' : 'text-text-muted'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{session.lastMessage}
|
{session.lastMessage}
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
||||||
cancelText='Cancel'
|
cancelText='Cancel'
|
||||||
confirmVariant='primary'
|
confirmVariant='primary'
|
||||||
>
|
>
|
||||||
<p className='text-gray-700'>
|
<p className='text-text-primary'>
|
||||||
This will scan the NOMAD's storage directories for any new files and queue them for processing. This is useful if you've manually added files to the storage or want to ensure everything is up to date.
|
This will scan the NOMAD's storage directories for any new files and queue them for processing. This is useful if you've manually added files to the storage or want to ensure everything is up to date.
|
||||||
This may cause a temporary increase in resource usage if new files are found and being processed. Are you sure you want to proceed?
|
This may cause a temporary increase in resource usage if new files are found and being processed. Are you sure you want to proceed?
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -117,18 +117,18 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/30 backdrop-blur-sm transition-opacity">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/30 backdrop-blur-sm transition-opacity">
|
||||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
<div className="bg-surface-primary rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 shrink-0">
|
<div className="flex items-center justify-between p-6 border-b border-border-subtle shrink-0">
|
||||||
<h2 className="text-2xl font-semibold text-gray-800">Knowledge Base</h2>
|
<h2 className="text-2xl font-semibold text-text-primary">Knowledge Base</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
className="p-2 hover:bg-surface-secondary rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<IconX className="h-6 w-6 text-gray-500" />
|
<IconX className="h-6 w-6 text-text-muted" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-y-auto flex-1 p-6">
|
<div className="overflow-y-auto flex-1 p-6">
|
||||||
<div className="bg-white rounded-lg border shadow-md overflow-hidden">
|
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<FileUploader
|
<FileUploader
|
||||||
ref={fileUploaderRef}
|
ref={fileUploaderRef}
|
||||||
|
|
@ -151,7 +151,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
||||||
</StyledButton>
|
</StyledButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t bg-white p-6">
|
<div className="border-t bg-surface-primary p-6">
|
||||||
<h3 className="text-lg font-semibold text-desert-green mb-4">
|
<h3 className="text-lg font-semibold text-desert-green mb-4">
|
||||||
Why upload documents to your Knowledge Base?
|
Why upload documents to your Knowledge Base?
|
||||||
</h3>
|
</h3>
|
||||||
|
|
@ -232,7 +232,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
||||||
accessor: 'source',
|
accessor: 'source',
|
||||||
title: 'File Name',
|
title: 'File Name',
|
||||||
render(record) {
|
render(record) {
|
||||||
return <span className="text-gray-700">{sourceToDisplayName(record.source)}</span>
|
return <span className="text-text-primary">{sourceToDisplayName(record.source)}</span>
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -244,7 +244,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
||||||
if (isConfirming) {
|
if (isConfirming) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 justify-end">
|
<div className="flex items-center gap-2 justify-end">
|
||||||
<span className="text-sm text-gray-600">Remove from knowledge base?</span>
|
<span className="text-sm text-text-secondary">Remove from knowledge base?</span>
|
||||||
<StyledButton
|
<StyledButton
|
||||||
variant='danger'
|
variant='danger'
|
||||||
size='sm'
|
size='sm'
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,7 @@ export default function Chat({
|
||||||
cancelText="Cancel"
|
cancelText="Cancel"
|
||||||
confirmVariant="danger"
|
confirmVariant="danger"
|
||||||
>
|
>
|
||||||
<p className="text-gray-700">
|
<p className="text-text-primary">
|
||||||
Are you sure you want to delete all chat sessions? This action cannot be undone and all
|
Are you sure you want to delete all chat sessions? This action cannot be undone and all
|
||||||
conversations will be permanently deleted.
|
conversations will be permanently deleted.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -345,7 +345,7 @@ export default function Chat({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'flex border border-gray-200 overflow-hidden shadow-sm w-full',
|
'flex border border-border-subtle overflow-hidden shadow-sm w-full',
|
||||||
isInModal ? 'h-full rounded-lg' : 'h-screen'
|
isInModal ? 'h-full rounded-lg' : 'h-screen'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -358,17 +358,17 @@ export default function Chat({
|
||||||
isInModal={isInModal}
|
isInModal={isInModal}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
<div className="px-6 py-3 border-b border-gray-200 bg-gray-50 flex items-center justify-between h-[75px] flex-shrink-0">
|
<div className="px-6 py-3 border-b border-border-subtle bg-surface-secondary flex items-center justify-between h-[75px] flex-shrink-0">
|
||||||
<h2 className="text-lg font-semibold text-gray-800">
|
<h2 className="text-lg font-semibold text-text-primary">
|
||||||
{activeSession?.title || 'New Chat'}
|
{activeSession?.title || 'New Chat'}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label htmlFor="model-select" className="text-sm text-gray-600">
|
<label htmlFor="model-select" className="text-sm text-text-secondary">
|
||||||
Model:
|
Model:
|
||||||
</label>
|
</label>
|
||||||
{isLoadingModels ? (
|
{isLoadingModels ? (
|
||||||
<div className="text-sm text-gray-500">Loading models...</div>
|
<div className="text-sm text-text-muted">Loading models...</div>
|
||||||
) : installedModels.length === 0 ? (
|
) : installedModels.length === 0 ? (
|
||||||
<div className="text-sm text-red-600">No models installed</div>
|
<div className="text-sm text-red-600">No models installed</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -376,7 +376,7 @@ export default function Chat({
|
||||||
id="model-select"
|
id="model-select"
|
||||||
value={selectedModel}
|
value={selectedModel}
|
||||||
onChange={(e) => setSelectedModel(e.target.value)}
|
onChange={(e) => setSelectedModel(e.target.value)}
|
||||||
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent bg-white"
|
className="px-3 py-1.5 border border-border-default rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent bg-surface-primary"
|
||||||
>
|
>
|
||||||
{installedModels.map((model) => (
|
{installedModels.map((model) => (
|
||||||
<option key={model.name} value={model.name}>
|
<option key={model.name} value={model.name}>
|
||||||
|
|
@ -393,9 +393,9 @@ export default function Chat({
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="rounded-lg hover:bg-gray-100 transition-colors"
|
className="rounded-lg hover:bg-surface-secondary transition-colors"
|
||||||
>
|
>
|
||||||
<IconX className="h-6 w-6 text-gray-500" />
|
<IconX className="h-6 w-6 text-text-muted" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -31,11 +31,11 @@ const Input: React.FC<InputProps> = ({
|
||||||
<div className={classNames(className)}>
|
<div className={classNames(className)}>
|
||||||
<label
|
<label
|
||||||
htmlFor={name}
|
htmlFor={name}
|
||||||
className={classNames("block text-base/6 font-medium text-gray-700", labelClassName)}
|
className={classNames("block text-base/6 font-medium text-text-primary", labelClassName)}
|
||||||
>
|
>
|
||||||
{label}{required ? "*" : ""}
|
{label}{required ? "*" : ""}
|
||||||
</label>
|
</label>
|
||||||
{helpText && <p className="mt-1 text-sm text-gray-500">{helpText}</p>}
|
{helpText && <p className="mt-1 text-sm text-text-muted">{helpText}</p>}
|
||||||
<div className={classNames("mt-1.5", containerClassName)}>
|
<div className={classNames("mt-1.5", containerClassName)}>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{leftIcon && (
|
{leftIcon && (
|
||||||
|
|
@ -49,7 +49,7 @@ const Input: React.FC<InputProps> = ({
|
||||||
placeholder={props.placeholder || label}
|
placeholder={props.placeholder || label}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
inputClassName,
|
inputClassName,
|
||||||
"block w-full rounded-md bg-white px-3 py-2 text-base text-gray-900 border border-gray-400 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-primary sm:text-sm/6",
|
"block w-full rounded-md bg-surface-primary px-3 py-2 text-base text-text-primary border border-border-default placeholder:text-text-muted focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-primary sm:text-sm/6",
|
||||||
leftIcon ? "pl-10" : "pl-3",
|
leftIcon ? "pl-10" : "pl-3",
|
||||||
error ? "!border-red-500 focus:outline-red-500 !bg-red-100" : ""
|
error ? "!border-red-500 focus:outline-red-500 !bg-red-100" : ""
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,12 @@ export default function Switch({
|
||||||
{label && (
|
{label && (
|
||||||
<label
|
<label
|
||||||
htmlFor={switchId}
|
htmlFor={switchId}
|
||||||
className="text-base font-medium text-gray-900 cursor-pointer"
|
className="text-base font-medium text-text-primary cursor-pointer"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
{description && <p className="text-sm text-gray-500 mt-1">{description}</p>}
|
{description && <p className="text-sm text-text-muted mt-1">{description}</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center ml-4">
|
<div className="flex items-center ml-4">
|
||||||
|
|
@ -45,7 +45,7 @@ export default function Switch({
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent',
|
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent',
|
||||||
'transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-desert-green focus:ring-offset-2',
|
'transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-desert-green focus:ring-offset-2',
|
||||||
checked ? 'bg-desert-green' : 'bg-gray-200',
|
checked ? 'bg-desert-green' : 'bg-border-default',
|
||||||
disabled ? 'opacity-50 cursor-not-allowed' : ''
|
disabled ? 'opacity-50 cursor-not-allowed' : ''
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,11 @@ interface BackToHomeHeaderProps {
|
||||||
|
|
||||||
export default function BackToHomeHeader({ className, children }: BackToHomeHeaderProps) {
|
export default function BackToHomeHeader({ className, children }: BackToHomeHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className={classNames('flex border-b border-gray-900/10 p-4', className)}>
|
<div className={classNames('flex border-b border-border-subtle p-4', className)}>
|
||||||
<div className="justify-self-start">
|
<div className="justify-self-start">
|
||||||
<Link href="/home" className="flex items-center">
|
<Link href="/home" className="flex items-center">
|
||||||
<IconArrowLeft className="mr-2" size={24} />
|
<IconArrowLeft className="mr-2" size={24} />
|
||||||
<p className="text-lg text-gray-600">Back to Home</p>
|
<p className="text-lg text-text-secondary">Back to Home</p>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-grow flex flex-col justify-center">{children}</div>
|
<div className="flex-grow flex flex-col justify-center">{children}</div>
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export default function MapComponent() {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100vh',
|
height: '100vh',
|
||||||
}}
|
}}
|
||||||
mapStyle={`http://${window.location.hostname}:${window.location.port}/api/maps/styles`}
|
mapStyle={`${window.location.protocol}//${window.location.hostname}:${window.location.port}/api/maps/styles`}
|
||||||
mapLib={maplibregl}
|
mapLib={maplibregl}
|
||||||
initialViewState={{
|
initialViewState={{
|
||||||
longitude: -101,
|
longitude: -101,
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,11 @@ export function Table({ children }: { children: React.ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TableHead({ children }: { children: React.ReactNode }) {
|
export function TableHead({ children }: { children: React.ReactNode }) {
|
||||||
return <thead className="bg-desert-green-dark">{children}</thead>
|
return <thead className="bg-desert-green">{children}</thead>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TableBody({ children }: { children: React.ReactNode }) {
|
export function TableBody({ children }: { children: React.ReactNode }) {
|
||||||
return <tbody className="divide-y divide-desert-tan-lighter/50 bg-white">{children}</tbody>
|
return <tbody className="divide-y divide-desert-tan-lighter/50 bg-surface-primary">{children}</tbody>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TableRow({ children }: { children: React.ReactNode }) {
|
export function TableRow({ children }: { children: React.ReactNode }) {
|
||||||
|
|
@ -22,7 +22,7 @@ export function TableRow({ children }: { children: React.ReactNode }) {
|
||||||
|
|
||||||
export function TableHeader({ children }: { children: React.ReactNode }) {
|
export function TableHeader({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<th className="px-5 py-3 text-left text-sm font-semibold text-desert-white tracking-wide">
|
<th className="px-5 py-3 text-left text-sm font-semibold text-white tracking-wide">
|
||||||
{children}
|
{children}
|
||||||
</th>
|
</th>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,8 @@ export default function InfoCard({ title, icon, data, variant = 'default' }: Inf
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="relative flex items-center gap-3">
|
<div className="relative flex items-center gap-3">
|
||||||
{icon && <div className="text-desert-white opacity-80">{icon}</div>}
|
{icon && <div className="text-white opacity-80">{icon}</div>}
|
||||||
<h3 className="text-lg font-bold text-desert-white uppercase tracking-wide">{title}</h3>
|
<h3 className="text-lg font-bold text-white uppercase tracking-wide">{title}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute top-0 right-0 w-24 h-24 transform translate-x-8 -translate-y-8">
|
<div className="absolute top-0 right-0 w-24 h-24 transform translate-x-8 -translate-y-8">
|
||||||
<div className="w-full h-full bg-desert-green-dark opacity-30 transform rotate-45" />
|
<div className="w-full h-full bg-desert-green-dark opacity-30 transform rotate-45" />
|
||||||
|
|
|
||||||
|
|
@ -3,39 +3,119 @@
|
||||||
@theme {
|
@theme {
|
||||||
--color-desert-white: #f6f6f4;
|
--color-desert-white: #f6f6f4;
|
||||||
--color-desert-sand: #f7eedc;
|
--color-desert-sand: #f7eedc;
|
||||||
|
|
||||||
--color-desert-green-darker: #2a2a15;
|
--color-desert-green-darker: #2a2a15;
|
||||||
--color-desert-green-dark: #353518;
|
--color-desert-green-dark: #353518;
|
||||||
--color-desert-green: #424420;
|
--color-desert-green: #424420;
|
||||||
--color-desert-green-light: #babaaa;
|
--color-desert-green-light: #babaaa;
|
||||||
--color-desert-green-lighter: #d4d4c8;
|
--color-desert-green-lighter: #d4d4c8;
|
||||||
|
|
||||||
--color-desert-orange-dark: #8a3d0f;
|
--color-desert-orange-dark: #8a3d0f;
|
||||||
--color-desert-orange: #a84a12;
|
--color-desert-orange: #a84a12;
|
||||||
--color-desert-orange-light: #c85815;
|
--color-desert-orange-light: #c85815;
|
||||||
--color-desert-orange-lighter: #e69556;
|
--color-desert-orange-lighter: #e69556;
|
||||||
|
|
||||||
--color-desert-tan-dark: #6b5d4f;
|
--color-desert-tan-dark: #6b5d4f;
|
||||||
--color-desert-tan: #8b7355;
|
--color-desert-tan: #8b7355;
|
||||||
--color-desert-tan-light: #a8927a;
|
--color-desert-tan-light: #a8927a;
|
||||||
--color-desert-tan-lighter: #c9b99f;
|
--color-desert-tan-lighter: #c9b99f;
|
||||||
|
|
||||||
--color-desert-red-dark: #7a2e2e;
|
--color-desert-red-dark: #7a2e2e;
|
||||||
--color-desert-red: #994444;
|
--color-desert-red: #994444;
|
||||||
--color-desert-red-light: #b05555;
|
--color-desert-red-light: #b05555;
|
||||||
--color-desert-red-lighter: #d88989;
|
--color-desert-red-lighter: #d88989;
|
||||||
|
|
||||||
--color-desert-olive-dark: #5a5c3a;
|
--color-desert-olive-dark: #5a5c3a;
|
||||||
--color-desert-olive: #6d7042;
|
--color-desert-olive: #6d7042;
|
||||||
--color-desert-olive-light: #858a55;
|
--color-desert-olive-light: #858a55;
|
||||||
--color-desert-olive-lighter: #a5ab7d;
|
--color-desert-olive-lighter: #a5ab7d;
|
||||||
|
|
||||||
--color-desert-stone-dark: #5c5c54;
|
--color-desert-stone-dark: #5c5c54;
|
||||||
--color-desert-stone: #75756a;
|
--color-desert-stone: #75756a;
|
||||||
--color-desert-stone-light: #8f8f82;
|
--color-desert-stone-light: #8f8f82;
|
||||||
--color-desert-stone-lighter: #afafa5;
|
--color-desert-stone-lighter: #afafa5;
|
||||||
|
|
||||||
|
/* Semantic surface/text tokens (for replacing generic gray/white Tailwind classes) */
|
||||||
|
--color-surface-primary: #ffffff;
|
||||||
|
--color-surface-secondary: #f9fafb;
|
||||||
|
--color-surface-elevated: #ffffff;
|
||||||
|
--color-text-primary: #111827;
|
||||||
|
--color-text-secondary: #6b7280;
|
||||||
|
--color-text-muted: #9ca3af;
|
||||||
|
--color-border-default: #d1d5db;
|
||||||
|
--color-border-subtle: #e5e7eb;
|
||||||
|
|
||||||
|
/* Button interactive states (green hover/active swap conflicts with text color inversion) */
|
||||||
|
--color-btn-green-hover: #353518;
|
||||||
|
--color-btn-green-active: #2a2a15;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--color-desert-sand);
|
background-color: var(--color-desert-sand);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Night Ops — warm charcoal dark mode */
|
||||||
|
[data-theme="dark"] {
|
||||||
|
/* Backgrounds: light sand → warm charcoal */
|
||||||
|
--color-desert-sand: #1c1b16;
|
||||||
|
--color-desert-white: #2a2918;
|
||||||
|
|
||||||
|
/* Text greens: dark text → light text for readability */
|
||||||
|
--color-desert-green-darker: #f7eedc;
|
||||||
|
--color-desert-green-dark: #e8dfc8;
|
||||||
|
|
||||||
|
/* Accent green: slightly brighter for dark bg visibility */
|
||||||
|
--color-desert-green: #525530;
|
||||||
|
|
||||||
|
/* Light variants → dark variants (hover bg, disabled states) */
|
||||||
|
--color-desert-green-light: #3a3c24;
|
||||||
|
--color-desert-green-lighter: #2d2e1c;
|
||||||
|
|
||||||
|
/* Orange: brighter for contrast on dark surfaces */
|
||||||
|
--color-desert-orange-dark: #c85815;
|
||||||
|
--color-desert-orange: #c85815;
|
||||||
|
--color-desert-orange-light: #e69556;
|
||||||
|
--color-desert-orange-lighter: #f0b87a;
|
||||||
|
|
||||||
|
/* Tan: lightened for readability */
|
||||||
|
--color-desert-tan-dark: #c9b99f;
|
||||||
|
--color-desert-tan: #a8927a;
|
||||||
|
--color-desert-tan-light: #8b7355;
|
||||||
|
--color-desert-tan-lighter: #6b5d4f;
|
||||||
|
|
||||||
|
/* Red: lightened for dark bg */
|
||||||
|
--color-desert-red-dark: #d88989;
|
||||||
|
--color-desert-red: #b05555;
|
||||||
|
--color-desert-red-light: #994444;
|
||||||
|
--color-desert-red-lighter: #7a2e2e;
|
||||||
|
|
||||||
|
/* Olive: lightened */
|
||||||
|
--color-desert-olive-dark: #a5ab7d;
|
||||||
|
--color-desert-olive: #858a55;
|
||||||
|
--color-desert-olive-light: #6d7042;
|
||||||
|
--color-desert-olive-lighter: #5a5c3a;
|
||||||
|
|
||||||
|
/* Stone: lightened */
|
||||||
|
--color-desert-stone-dark: #afafa5;
|
||||||
|
--color-desert-stone: #8f8f82;
|
||||||
|
--color-desert-stone-light: #75756a;
|
||||||
|
--color-desert-stone-lighter: #5c5c54;
|
||||||
|
|
||||||
|
/* Semantic surface overrides */
|
||||||
|
--color-surface-primary: #2a2918;
|
||||||
|
--color-surface-secondary: #353420;
|
||||||
|
--color-surface-elevated: #3d3c2a;
|
||||||
|
--color-text-primary: #f7eedc;
|
||||||
|
--color-text-secondary: #afafa5;
|
||||||
|
--color-text-muted: #8f8f82;
|
||||||
|
--color-border-default: #424420;
|
||||||
|
--color-border-subtle: #353420;
|
||||||
|
|
||||||
|
/* Button interactive states: darker green for hover/active on dark bg */
|
||||||
|
--color-btn-green-hover: #474a28;
|
||||||
|
--color-btn-green-active: #3a3c24;
|
||||||
|
|
||||||
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
82
admin/inertia/hooks/useDiskDisplayData.ts
Normal file
82
admin/inertia/hooks/useDiskDisplayData.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { NomadDiskInfo } from '../../types/system'
|
||||||
|
import { Systeminformation } from 'systeminformation'
|
||||||
|
import { formatBytes } from '~/lib/util'
|
||||||
|
|
||||||
|
type DiskDisplayItem = {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
total: string
|
||||||
|
used: string
|
||||||
|
subtext: string
|
||||||
|
totalBytes: number
|
||||||
|
usedBytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all valid disks formatted for display (settings/system page) */
|
||||||
|
export function getAllDiskDisplayItems(
|
||||||
|
disks: NomadDiskInfo[] | undefined,
|
||||||
|
fsSize: Systeminformation.FsSizeData[] | undefined
|
||||||
|
): DiskDisplayItem[] {
|
||||||
|
const validDisks = disks?.filter((d) => d.totalSize > 0) || []
|
||||||
|
|
||||||
|
if (validDisks.length > 0) {
|
||||||
|
return validDisks.map((disk) => ({
|
||||||
|
label: disk.name || 'Unknown',
|
||||||
|
value: disk.percentUsed || 0,
|
||||||
|
total: formatBytes(disk.totalSize),
|
||||||
|
used: formatBytes(disk.totalUsed),
|
||||||
|
subtext: `${formatBytes(disk.totalUsed || 0)} / ${formatBytes(disk.totalSize || 0)}`,
|
||||||
|
totalBytes: disk.totalSize,
|
||||||
|
usedBytes: disk.totalUsed,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fsSize && fsSize.length > 0) {
|
||||||
|
const seen = new Set<number>()
|
||||||
|
const uniqueFs = fsSize.filter((fs) => {
|
||||||
|
if (fs.size <= 0 || seen.has(fs.size)) return false
|
||||||
|
seen.add(fs.size)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
const realDevices = uniqueFs.filter((fs) => fs.fs.startsWith('/dev/'))
|
||||||
|
const displayFs = realDevices.length > 0 ? realDevices : uniqueFs
|
||||||
|
return displayFs.map((fs) => ({
|
||||||
|
label: fs.fs || 'Unknown',
|
||||||
|
value: fs.use || 0,
|
||||||
|
total: formatBytes(fs.size),
|
||||||
|
used: formatBytes(fs.used),
|
||||||
|
subtext: `${formatBytes(fs.used)} / ${formatBytes(fs.size)}`,
|
||||||
|
totalBytes: fs.size,
|
||||||
|
usedBytes: fs.used,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get primary disk info for storage projection (easy-setup page) */
|
||||||
|
export function getPrimaryDiskInfo(
|
||||||
|
disks: NomadDiskInfo[] | undefined,
|
||||||
|
fsSize: Systeminformation.FsSizeData[] | undefined
|
||||||
|
): { totalSize: number; totalUsed: number } | null {
|
||||||
|
const validDisks = disks?.filter((d) => d.totalSize > 0) || []
|
||||||
|
if (validDisks.length > 0) {
|
||||||
|
const diskWithRoot = validDisks.find((d) =>
|
||||||
|
d.filesystems?.some((fs) => fs.mount === '/' || fs.mount === '/storage')
|
||||||
|
)
|
||||||
|
const primary =
|
||||||
|
diskWithRoot || validDisks.reduce((a, b) => (b.totalSize > a.totalSize ? b : a))
|
||||||
|
return { totalSize: primary.totalSize, totalUsed: primary.totalUsed }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fsSize && fsSize.length > 0) {
|
||||||
|
const realDevices = fsSize.filter((fs) => fs.fs.startsWith('/dev/'))
|
||||||
|
const primary =
|
||||||
|
realDevices.length > 0
|
||||||
|
? realDevices.reduce((a, b) => (b.size > a.size ? b : a))
|
||||||
|
: fsSize[0]
|
||||||
|
return { totalSize: primary.size, totalUsed: primary.used }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,11 @@ const useDownloads = (props: useDownloadsProps) => {
|
||||||
const queryData = useQuery({
|
const queryData = useQuery({
|
||||||
queryKey: queryKey,
|
queryKey: queryKey,
|
||||||
queryFn: () => api.listDownloadJobs(props.filetype),
|
queryFn: () => api.listDownloadJobs(props.filetype),
|
||||||
refetchInterval: 2000, // Refetch every 2 seconds to get updated progress
|
refetchInterval: (query) => {
|
||||||
|
const data = query.state.data
|
||||||
|
// Only poll when there are active downloads; otherwise use a slower interval
|
||||||
|
return data && data.length > 0 ? 2000 : 30000
|
||||||
|
},
|
||||||
enabled: props.enabled ?? true,
|
enabled: props.enabled ?? true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,11 @@ const useEmbedJobs = (props: { enabled?: boolean } = {}) => {
|
||||||
const queryData = useQuery({
|
const queryData = useQuery({
|
||||||
queryKey: ['embed-jobs'],
|
queryKey: ['embed-jobs'],
|
||||||
queryFn: () => api.getActiveEmbedJobs().then((data) => data ?? []),
|
queryFn: () => api.getActiveEmbedJobs().then((data) => data ?? []),
|
||||||
refetchInterval: 2000,
|
refetchInterval: (query) => {
|
||||||
|
const data = query.state.data
|
||||||
|
// Only poll when there are active jobs; otherwise use a slower interval
|
||||||
|
return data && data.length > 0 ? 2000 : 30000
|
||||||
|
},
|
||||||
enabled: props.enabled ?? true,
|
enabled: props.enabled ?? true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,47 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useTransmit } from 'react-adonis-transmit'
|
import { useTransmit } from 'react-adonis-transmit'
|
||||||
|
|
||||||
export type OllamaModelDownload = {
|
export type OllamaModelDownload = {
|
||||||
model: string
|
model: string
|
||||||
percent: number
|
percent: number
|
||||||
timestamp: string
|
timestamp: string
|
||||||
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useOllamaModelDownloads() {
|
export default function useOllamaModelDownloads() {
|
||||||
const { subscribe } = useTransmit()
|
const { subscribe } = useTransmit()
|
||||||
const [downloads, setDownloads] = useState<Map<string, OllamaModelDownload>>(new Map())
|
const [downloads, setDownloads] = useState<Map<string, OllamaModelDownload>>(new Map())
|
||||||
|
const timeoutsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = subscribe('ollama-model-download', (data: OllamaModelDownload) => {
|
const unsubscribe = subscribe('ollama-model-download', (data: OllamaModelDownload) => {
|
||||||
setDownloads((prev) => {
|
setDownloads((prev) => {
|
||||||
const updated = new Map(prev)
|
const updated = new Map(prev)
|
||||||
|
|
||||||
if (data.percent >= 100) {
|
if (data.percent === -1) {
|
||||||
|
// Download failed — show error state, auto-remove after 15 seconds
|
||||||
|
updated.set(data.model, data)
|
||||||
|
const errorTimeout = setTimeout(() => {
|
||||||
|
timeoutsRef.current.delete(errorTimeout)
|
||||||
|
setDownloads((current) => {
|
||||||
|
const next = new Map(current)
|
||||||
|
next.delete(data.model)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, 15000)
|
||||||
|
timeoutsRef.current.add(errorTimeout)
|
||||||
|
} else if (data.percent >= 100) {
|
||||||
// If download is complete, keep it for a short time before removing to allow UI to show 100% progress
|
// If download is complete, keep it for a short time before removing to allow UI to show 100% progress
|
||||||
updated.set(data.model, data)
|
updated.set(data.model, data)
|
||||||
setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
|
timeoutsRef.current.delete(timeout)
|
||||||
setDownloads((current) => {
|
setDownloads((current) => {
|
||||||
const next = new Map(current)
|
const next = new Map(current)
|
||||||
next.delete(data.model)
|
next.delete(data.model)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}, 2000)
|
}, 2000)
|
||||||
|
timeoutsRef.current.add(timeout)
|
||||||
} else {
|
} else {
|
||||||
updated.set(data.model, data)
|
updated.set(data.model, data)
|
||||||
}
|
}
|
||||||
|
|
@ -36,7 +52,10 @@ export default function useOllamaModelDownloads() {
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe()
|
unsubscribe()
|
||||||
|
timeoutsRef.current.forEach(clearTimeout)
|
||||||
|
timeoutsRef.current.clear()
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [subscribe])
|
}, [subscribe])
|
||||||
|
|
||||||
const downloadsArray = Array.from(downloads.values())
|
const downloadsArray = Array.from(downloads.values())
|
||||||
|
|
|
||||||
47
admin/inertia/hooks/useTheme.ts
Normal file
47
admin/inertia/hooks/useTheme.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import api from '~/lib/api'
|
||||||
|
|
||||||
|
export type Theme = 'light' | 'dark'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'nomad:theme'
|
||||||
|
|
||||||
|
function getInitialTheme(): Theme {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (stored === 'dark' || stored === 'light') return stored
|
||||||
|
} catch {}
|
||||||
|
return 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const [theme, setThemeState] = useState<Theme>(getInitialTheme)
|
||||||
|
|
||||||
|
const setTheme = useCallback((newTheme: Theme) => {
|
||||||
|
setThemeState(newTheme)
|
||||||
|
document.documentElement.setAttribute('data-theme', newTheme)
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, newTheme)
|
||||||
|
} catch {}
|
||||||
|
// Fire-and-forget KV store sync for cross-device persistence
|
||||||
|
api.updateSetting('ui.theme', newTheme).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleTheme = useCallback(() => {
|
||||||
|
setThemeState((prev) => {
|
||||||
|
const next = prev === 'light' ? 'dark' : 'light'
|
||||||
|
document.documentElement.setAttribute('data-theme', next)
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, next)
|
||||||
|
} catch {}
|
||||||
|
api.updateSetting('ui.theme', next).catch(() => {})
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Apply theme on mount
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { theme, setTheme, toggleTheme }
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
window.location.pathname !== '/home' && (
|
window.location.pathname !== '/home' && (
|
||||||
<Link href="/home" className="absolute top-60 md:top-48 left-4 flex items-center">
|
<Link href="/home" className="absolute top-60 md:top-48 left-4 flex items-center">
|
||||||
<IconArrowLeft className="mr-2" size={24} />
|
<IconArrowLeft className="mr-2" size={24} />
|
||||||
<p className="text-lg text-gray-600">Back to Home</p>
|
<p className="text-lg text-text-secondary">Back to Home</p>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
IconDashboard,
|
IconDashboard,
|
||||||
IconFolder,
|
IconFolder,
|
||||||
IconGavel,
|
IconGavel,
|
||||||
|
IconHeart,
|
||||||
IconMapRoute,
|
IconMapRoute,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconTerminal2,
|
IconTerminal2,
|
||||||
|
|
@ -41,11 +42,12 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
|
||||||
current: false,
|
current: false,
|
||||||
},
|
},
|
||||||
{ name: 'System', href: '/settings/system', icon: IconSettings, current: false },
|
{ name: 'System', href: '/settings/system', icon: IconSettings, current: false },
|
||||||
|
{ name: 'Support the Project', href: '/settings/support', icon: IconHeart, current: false },
|
||||||
{ name: 'Legal Notices', href: '/settings/legal', icon: IconGavel, current: false },
|
{ name: 'Legal Notices', href: '/settings/legal', icon: IconGavel, current: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-row bg-stone-50/90">
|
<div className="min-h-screen flex flex-row bg-surface-secondary/90">
|
||||||
<StyledSidebar title="Settings" items={navigation} />
|
<StyledSidebar title="Settings" items={navigation} />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import axios, { AxiosInstance } from 'axios'
|
import axios, { AxiosError, AxiosInstance } from 'axios'
|
||||||
import { ListRemoteZimFilesResponse, ListZimFilesResponse } from '../../types/zim'
|
import { ListRemoteZimFilesResponse, ListZimFilesResponse } from '../../types/zim'
|
||||||
import { ServiceSlim } from '../../types/services'
|
import { ServiceSlim } from '../../types/services'
|
||||||
import { FileEntry } from '../../types/files'
|
import { FileEntry } from '../../types/files'
|
||||||
|
|
@ -25,13 +25,19 @@ class API {
|
||||||
}
|
}
|
||||||
|
|
||||||
async affectService(service_name: string, action: 'start' | 'stop' | 'restart') {
|
async affectService(service_name: string, action: 'start' | 'stop' | 'restart') {
|
||||||
return catchInternal(async () => {
|
try {
|
||||||
const response = await this.client.post<{ success: boolean; message: string }>(
|
const response = await this.client.post<{ success: boolean; message: string }>(
|
||||||
'/system/services/affect',
|
'/system/services/affect',
|
||||||
{ service_name, action }
|
{ service_name, action }
|
||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
})()
|
} catch (error) {
|
||||||
|
if (error instanceof AxiosError && error.response?.data?.message) {
|
||||||
|
return { success: false, message: error.response.data.message }
|
||||||
|
}
|
||||||
|
console.error('Error affecting service:', error)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkLatestVersion(force: boolean = false) {
|
async checkLatestVersion(force: boolean = false) {
|
||||||
|
|
@ -192,13 +198,19 @@ class API {
|
||||||
}
|
}
|
||||||
|
|
||||||
async forceReinstallService(service_name: string) {
|
async forceReinstallService(service_name: string) {
|
||||||
return catchInternal(async () => {
|
try {
|
||||||
const response = await this.client.post<{ success: boolean; message: string }>(
|
const response = await this.client.post<{ success: boolean; message: string }>(
|
||||||
`/system/services/force-reinstall`,
|
`/system/services/force-reinstall`,
|
||||||
{ service_name }
|
{ service_name }
|
||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
})()
|
} catch (error) {
|
||||||
|
if (error instanceof AxiosError && error.response?.data?.message) {
|
||||||
|
return { success: false, message: error.response.data.message }
|
||||||
|
}
|
||||||
|
console.error('Error force reinstalling service:', error)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getChatSuggestions(signal?: AbortSignal) {
|
async getChatSuggestions(signal?: AbortSignal) {
|
||||||
|
|
@ -211,6 +223,13 @@ class API {
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getDebugInfo() {
|
||||||
|
return catchInternal(async () => {
|
||||||
|
const response = await this.client.get<{ debugInfo: string }>('/system/debug-info')
|
||||||
|
return response.data.debugInfo
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
async getInternetStatus() {
|
async getInternetStatus() {
|
||||||
return catchInternal(async () => {
|
return catchInternal(async () => {
|
||||||
const response = await this.client.get<boolean>('/system/internet-status')
|
const response = await this.client.get<boolean>('/system/internet-status')
|
||||||
|
|
@ -452,13 +471,19 @@ class API {
|
||||||
}
|
}
|
||||||
|
|
||||||
async installService(service_name: string) {
|
async installService(service_name: string) {
|
||||||
return catchInternal(async () => {
|
try {
|
||||||
const response = await this.client.post<{ success: boolean; message: string }>(
|
const response = await this.client.post<{ success: boolean; message: string }>(
|
||||||
'/system/services/install',
|
'/system/services/install',
|
||||||
{ service_name }
|
{ service_name }
|
||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
})()
|
} catch (error) {
|
||||||
|
if (error instanceof AxiosError && error.response?.data?.message) {
|
||||||
|
return { success: false, message: error.response.data.message }
|
||||||
|
}
|
||||||
|
console.error('Error installing service:', error)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async listCuratedMapCollections() {
|
async listCuratedMapCollections() {
|
||||||
|
|
@ -511,6 +536,13 @@ class API {
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteZimFile(filename: string) {
|
||||||
|
return catchInternal(async () => {
|
||||||
|
const response = await this.client.delete<{ message: string }>(`/zim/${filename}`)
|
||||||
|
return response.data
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
async listZimFiles() {
|
async listZimFiles() {
|
||||||
return catchInternal(async () => {
|
return catchInternal(async () => {
|
||||||
return await this.client.get<ListZimFilesResponse>('/zim/list')
|
return await this.client.get<ListZimFilesResponse>('/zim/list')
|
||||||
|
|
@ -525,6 +557,12 @@ class API {
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async removeDownloadJob(jobId: string): Promise<void> {
|
||||||
|
return catchInternal(async () => {
|
||||||
|
await this.client.delete(`/downloads/jobs/${jobId}`)
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
async runBenchmark(type: BenchmarkType, sync: boolean = false) {
|
async runBenchmark(type: BenchmarkType, sync: boolean = false) {
|
||||||
return catchInternal(async () => {
|
return catchInternal(async () => {
|
||||||
const response = await this.client.post<RunBenchmarkResponse>(
|
const response = await this.client.post<RunBenchmarkResponse>(
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export default function EasySetupWizardComplete() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||||
<div className="bg-white rounded-md shadow-md p-6">
|
<div className="bg-surface-primary rounded-md shadow-md p-6">
|
||||||
<StyledSectionHeader title="App Installation Activity" className=" mb-4" />
|
<StyledSectionHeader title="App Installation Activity" className=" mb-4" />
|
||||||
<InstallActivityFeed
|
<InstallActivityFeed
|
||||||
activity={installActivity}
|
activity={installActivity}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import StorageProjectionBar from '~/components/StorageProjectionBar'
|
||||||
import { useNotifications } from '~/context/NotificationContext'
|
import { useNotifications } from '~/context/NotificationContext'
|
||||||
import useInternetStatus from '~/hooks/useInternetStatus'
|
import useInternetStatus from '~/hooks/useInternetStatus'
|
||||||
import { useSystemInfo } from '~/hooks/useSystemInfo'
|
import { useSystemInfo } from '~/hooks/useSystemInfo'
|
||||||
|
import { getPrimaryDiskInfo } from '~/hooks/useDiskDisplayData'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import type { CategoryWithStatus, SpecTier, SpecResource } from '../../../types/collections'
|
import type { CategoryWithStatus, SpecTier, SpecResource } from '../../../types/collections'
|
||||||
import { resolveTierResources } from '~/lib/collections'
|
import { resolveTierResources } from '~/lib/collections'
|
||||||
|
|
@ -296,34 +297,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
])
|
])
|
||||||
|
|
||||||
// Get primary disk/filesystem info for storage projection
|
// Get primary disk/filesystem info for storage projection
|
||||||
// Try disk array first (Linux/production), fall back to fsSize (Windows/dev)
|
const storageInfo = getPrimaryDiskInfo(systemInfo?.disk, systemInfo?.fsSize)
|
||||||
// Filter out invalid disks (totalSize === 0) and prefer disk with root mount or largest valid disk
|
|
||||||
const getPrimaryDisk = () => {
|
|
||||||
if (!systemInfo?.disk || systemInfo.disk.length === 0) return null
|
|
||||||
|
|
||||||
// Filter to only valid disks with actual storage
|
|
||||||
const validDisks = systemInfo.disk.filter((d) => d.totalSize > 0)
|
|
||||||
if (validDisks.length === 0) return null
|
|
||||||
|
|
||||||
// Prefer disk containing root mount (/) or /storage mount
|
|
||||||
const diskWithRoot = validDisks.find((d) =>
|
|
||||||
d.filesystems?.some((fs) => fs.mount === '/' || fs.mount === '/storage')
|
|
||||||
)
|
|
||||||
if (diskWithRoot) return diskWithRoot
|
|
||||||
|
|
||||||
// Fall back to largest valid disk
|
|
||||||
return validDisks.reduce((largest, current) =>
|
|
||||||
current.totalSize > largest.totalSize ? current : largest
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const primaryDisk = getPrimaryDisk()
|
|
||||||
const primaryFs = systemInfo?.fsSize?.[0]
|
|
||||||
const storageInfo = primaryDisk
|
|
||||||
? { totalSize: primaryDisk.totalSize, totalUsed: primaryDisk.totalUsed }
|
|
||||||
: primaryFs
|
|
||||||
? { totalSize: primaryFs.size, totalUsed: primaryFs.used }
|
|
||||||
: null
|
|
||||||
|
|
||||||
const canProceedToNextStep = () => {
|
const canProceedToNextStep = () => {
|
||||||
if (!isOnline) return false // Must be online to proceed
|
if (!isOnline) return false // Must be online to proceed
|
||||||
|
|
@ -444,7 +418,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
<nav aria-label="Progress" className="px-6 pt-6">
|
<nav aria-label="Progress" className="px-6 pt-6">
|
||||||
<ol
|
<ol
|
||||||
role="list"
|
role="list"
|
||||||
className="divide-y divide-gray-300 rounded-md md:flex md:divide-y-0 md:justify-between border border-desert-green"
|
className="divide-y divide-border-default rounded-md md:flex md:divide-y-0 md:justify-between border border-desert-green"
|
||||||
>
|
>
|
||||||
{steps.map((step, stepIdx) => (
|
{steps.map((step, stepIdx) => (
|
||||||
<li key={step.number} className="relative md:flex-1 md:flex md:justify-center">
|
<li key={step.number} className="relative md:flex-1 md:flex md:justify-center">
|
||||||
|
|
@ -454,7 +428,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-desert-green">
|
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-desert-green">
|
||||||
<IconCheck aria-hidden="true" className="size-6 text-white" />
|
<IconCheck aria-hidden="true" className="size-6 text-white" />
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-4 text-lg font-medium text-gray-900">{step.label}</span>
|
<span className="ml-4 text-lg font-medium text-text-primary">{step.label}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : currentStep === step.number ? (
|
) : currentStep === step.number ? (
|
||||||
|
|
@ -470,10 +444,10 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
) : (
|
) : (
|
||||||
<div className="group flex items-center md:justify-center">
|
<div className="group flex items-center md:justify-center">
|
||||||
<span className="flex items-center px-6 py-2 text-sm font-medium">
|
<span className="flex items-center px-6 py-2 text-sm font-medium">
|
||||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-full border-2 border-gray-300">
|
<span className="flex size-10 shrink-0 items-center justify-center rounded-full border-2 border-border-default">
|
||||||
<span className="text-gray-500">{step.number}</span>
|
<span className="text-text-muted">{step.number}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-4 text-lg font-medium text-gray-500">{step.label}</span>
|
<span className="ml-4 text-lg font-medium text-text-muted">{step.label}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -489,7 +463,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 22 80"
|
viewBox="0 0 22 80"
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
className={`size-full ${currentStep > step.number ? 'text-desert-green' : 'text-gray-300'}`}
|
className={`size-full ${currentStep > step.number ? 'text-desert-green' : 'text-text-muted'}`}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 -2L20 40L0 82"
|
d="M0 -2L20 40L0 82"
|
||||||
|
|
@ -565,7 +539,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
? 'border-desert-green bg-desert-green/20 cursor-default'
|
? 'border-desert-green bg-desert-green/20 cursor-default'
|
||||||
: selected
|
: selected
|
||||||
? 'border-desert-green bg-desert-green shadow-md cursor-pointer'
|
? 'border-desert-green bg-desert-green shadow-md cursor-pointer'
|
||||||
: 'border-desert-stone-light bg-white hover:border-desert-green hover:shadow-sm cursor-pointer'
|
: 'border-desert-stone-light bg-surface-primary hover:border-desert-green hover:shadow-sm cursor-pointer'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
|
|
@ -574,7 +548,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
<h3
|
<h3
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'text-xl font-bold',
|
'text-xl font-bold',
|
||||||
installed ? 'text-gray-700' : selected ? 'text-white' : 'text-gray-900'
|
installed ? 'text-text-primary' : selected ? 'text-white' : 'text-text-primary'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{capability.name}
|
{capability.name}
|
||||||
|
|
@ -588,7 +562,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
<p
|
<p
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'text-sm mt-0.5',
|
'text-sm mt-0.5',
|
||||||
installed ? 'text-gray-500' : selected ? 'text-green-100' : 'text-gray-500'
|
installed ? 'text-text-muted' : selected ? 'text-green-100' : 'text-text-muted'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Powered by {capability.technicalName}
|
Powered by {capability.technicalName}
|
||||||
|
|
@ -596,7 +570,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
<p
|
<p
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'text-sm mt-3',
|
'text-sm mt-3',
|
||||||
installed ? 'text-gray-600' : selected ? 'text-white' : 'text-gray-600'
|
installed ? 'text-text-secondary' : selected ? 'text-white' : 'text-text-secondary'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{capability.description}
|
{capability.description}
|
||||||
|
|
@ -605,7 +579,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
<ul
|
<ul
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'mt-3 space-y-1',
|
'mt-3 space-y-1',
|
||||||
installed ? 'text-gray-600' : selected ? 'text-white' : 'text-gray-600'
|
installed ? 'text-text-secondary' : selected ? 'text-white' : 'text-text-secondary'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{capability.features.map((feature, idx) => (
|
{capability.features.map((feature, idx) => (
|
||||||
|
|
@ -661,15 +635,15 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">What do you want NOMAD to do?</h2>
|
<h2 className="text-3xl font-bold text-text-primary mb-2">What do you want NOMAD to do?</h2>
|
||||||
<p className="text-gray-600">
|
<p className="text-text-secondary">
|
||||||
Select the capabilities you need. You can always add more later.
|
Select the capabilities you need. You can always add more later.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{allInstalled ? (
|
{allInstalled ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-gray-600 text-lg">
|
<p className="text-text-secondary text-lg">
|
||||||
All available capabilities are already installed!
|
All available capabilities are already installed!
|
||||||
</p>
|
</p>
|
||||||
<StyledButton
|
<StyledButton
|
||||||
|
|
@ -685,7 +659,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
{/* Core Capabilities */}
|
{/* Core Capabilities */}
|
||||||
{existingCoreCapabilities.length > 0 && (
|
{existingCoreCapabilities.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-700 mb-4">Core Capabilities</h3>
|
<h3 className="text-lg font-semibold text-text-primary mb-4">Core Capabilities</h3>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
{existingCoreCapabilities.map((capability) =>
|
{existingCoreCapabilities.map((capability) =>
|
||||||
renderCapabilityCard(capability, true)
|
renderCapabilityCard(capability, true)
|
||||||
|
|
@ -701,11 +675,11 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
onClick={() => setShowAdditionalTools(!showAdditionalTools)}
|
onClick={() => setShowAdditionalTools(!showAdditionalTools)}
|
||||||
className="flex items-center justify-between w-full text-left"
|
className="flex items-center justify-between w-full text-left"
|
||||||
>
|
>
|
||||||
<h3 className="text-md font-medium text-gray-500">Additional Tools</h3>
|
<h3 className="text-md font-medium text-text-muted">Additional Tools</h3>
|
||||||
{showAdditionalTools ? (
|
{showAdditionalTools ? (
|
||||||
<IconChevronUp size={20} className="text-gray-400" />
|
<IconChevronUp size={20} className="text-text-muted" />
|
||||||
) : (
|
) : (
|
||||||
<IconChevronDown size={20} className="text-gray-400" />
|
<IconChevronDown size={20} className="text-text-muted" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{showAdditionalTools && (
|
{showAdditionalTools && (
|
||||||
|
|
@ -726,8 +700,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
const renderStep2 = () => (
|
const renderStep2 = () => (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">Choose Map Regions</h2>
|
<h2 className="text-3xl font-bold text-text-primary mb-2">Choose Map Regions</h2>
|
||||||
<p className="text-gray-600">
|
<p className="text-text-secondary">
|
||||||
Select map region collections to download for offline use. You can always download more
|
Select map region collections to download for offline use. You can always download more
|
||||||
regions later.
|
regions later.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -763,7 +737,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-gray-600 text-lg">No map collections available at this time.</p>
|
<p className="text-text-secondary text-lg">No map collections available at this time.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -779,8 +753,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">Choose Content</h2>
|
<h2 className="text-3xl font-bold text-text-primary mb-2">Choose Content</h2>
|
||||||
<p className="text-gray-600">
|
<p className="text-text-secondary">
|
||||||
{isAiSelected && isInformationSelected
|
{isAiSelected && isInformationSelected
|
||||||
? 'Select AI models and content categories for offline use.'
|
? 'Select AI models and content categories for offline use.'
|
||||||
: isAiSelected
|
: isAiSelected
|
||||||
|
|
@ -795,12 +769,12 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
{isAiSelected && (
|
{isAiSelected && (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<div className="w-10 h-10 rounded-full bg-white border border-gray-200 flex items-center justify-center shadow-sm">
|
<div className="w-10 h-10 rounded-full bg-surface-primary border border-border-subtle flex items-center justify-center shadow-sm">
|
||||||
<IconCpu className="w-6 h-6 text-gray-700" />
|
<IconCpu className="w-6 h-6 text-text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-semibold text-gray-900">AI Models</h3>
|
<h3 className="text-xl font-semibold text-text-primary">AI Models</h3>
|
||||||
<p className="text-sm text-gray-500">Select models to download for offline AI</p>
|
<p className="text-sm text-text-muted">Select models to download for offline AI</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -818,7 +792,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
'p-4 rounded-lg border-2 transition-all cursor-pointer',
|
'p-4 rounded-lg border-2 transition-all cursor-pointer',
|
||||||
selectedAiModels.includes(model.name)
|
selectedAiModels.includes(model.name)
|
||||||
? 'border-desert-green bg-desert-green shadow-md'
|
? 'border-desert-green bg-desert-green shadow-md'
|
||||||
: 'border-desert-stone-light bg-white hover:border-desert-green hover:shadow-sm',
|
: 'border-desert-stone-light bg-surface-primary hover:border-desert-green hover:shadow-sm',
|
||||||
!isOnline && 'opacity-50 cursor-not-allowed'
|
!isOnline && 'opacity-50 cursor-not-allowed'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -827,7 +801,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
<h4
|
<h4
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'text-lg font-semibold mb-1',
|
'text-lg font-semibold mb-1',
|
||||||
selectedAiModels.includes(model.name) ? 'text-white' : 'text-gray-900'
|
selectedAiModels.includes(model.name) ? 'text-white' : 'text-text-primary'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{model.name}
|
{model.name}
|
||||||
|
|
@ -835,7 +809,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
<p
|
<p
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'text-sm mb-2',
|
'text-sm mb-2',
|
||||||
selectedAiModels.includes(model.name) ? 'text-white' : 'text-gray-600'
|
selectedAiModels.includes(model.name) ? 'text-white' : 'text-text-secondary'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{model.description}
|
{model.description}
|
||||||
|
|
@ -846,7 +820,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
'text-xs',
|
'text-xs',
|
||||||
selectedAiModels.includes(model.name)
|
selectedAiModels.includes(model.name)
|
||||||
? 'text-green-100'
|
? 'text-green-100'
|
||||||
: 'text-gray-500'
|
: 'text-text-muted'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Size: {model.tags[0].size}
|
Size: {model.tags[0].size}
|
||||||
|
|
@ -870,8 +844,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8 bg-gray-50 rounded-lg">
|
<div className="text-center py-8 bg-surface-secondary rounded-lg">
|
||||||
<p className="text-gray-600">No recommended AI models available at this time.</p>
|
<p className="text-text-secondary">No recommended AI models available at this time.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -881,7 +855,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
{isInformationSelected && (
|
{isInformationSelected && (
|
||||||
<>
|
<>
|
||||||
{/* Divider between AI Models and Wikipedia */}
|
{/* Divider between AI Models and Wikipedia */}
|
||||||
{isAiSelected && <hr className="my-8 border-gray-200" />}
|
{isAiSelected && <hr className="my-8 border-border-subtle" />}
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
{isLoadingWikipedia ? (
|
{isLoadingWikipedia ? (
|
||||||
|
|
@ -905,15 +879,15 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
{isInformationSelected && (
|
{isInformationSelected && (
|
||||||
<>
|
<>
|
||||||
{/* Divider between Wikipedia and Additional Content */}
|
{/* Divider between Wikipedia and Additional Content */}
|
||||||
<hr className="my-8 border-gray-200" />
|
<hr className="my-8 border-border-subtle" />
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<div className="w-10 h-10 rounded-full bg-white border border-gray-200 flex items-center justify-center shadow-sm">
|
<div className="w-10 h-10 rounded-full bg-surface-primary border border-border-subtle flex items-center justify-center shadow-sm">
|
||||||
<IconBooks className="w-6 h-6 text-gray-700" />
|
<IconBooks className="w-6 h-6 text-text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-semibold text-gray-900">Additional Content</h3>
|
<h3 className="text-xl font-semibold text-text-primary">Additional Content</h3>
|
||||||
<p className="text-sm text-gray-500">Curated collections for offline reference</p>
|
<p className="text-sm text-text-muted">Curated collections for offline reference</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -955,7 +929,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
{/* Show message if no capabilities requiring content are selected */}
|
{/* Show message if no capabilities requiring content are selected */}
|
||||||
{!isAiSelected && !isInformationSelected && (
|
{!isAiSelected && !isInformationSelected && (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-gray-600 text-lg">
|
<p className="text-text-secondary text-lg">
|
||||||
No content-based capabilities selected. You can skip this step or go back to select
|
No content-based capabilities selected. You can skip this step or go back to select
|
||||||
capabilities that require content.
|
capabilities that require content.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -976,8 +950,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">Review Your Selections</h2>
|
<h2 className="text-3xl font-bold text-text-primary mb-2">Review Your Selections</h2>
|
||||||
<p className="text-gray-600">Review your choices before starting the setup process.</p>
|
<p className="text-text-secondary">Review your choices before starting the setup process.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!hasSelections ? (
|
{!hasSelections ? (
|
||||||
|
|
@ -990,8 +964,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{selectedServices.length > 0 && (
|
{selectedServices.length > 0 && (
|
||||||
<div className="bg-white rounded-lg border-2 border-desert-stone-light p-6">
|
<div className="bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6">
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-4">
|
<h3 className="text-xl font-semibold text-text-primary mb-4">
|
||||||
Capabilities to Install
|
Capabilities to Install
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
|
|
@ -1000,9 +974,9 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
.map((capability) => (
|
.map((capability) => (
|
||||||
<li key={capability.id} className="flex items-center">
|
<li key={capability.id} className="flex items-center">
|
||||||
<IconCheck size={20} className="text-desert-green mr-2" />
|
<IconCheck size={20} className="text-desert-green mr-2" />
|
||||||
<span className="text-gray-700">
|
<span className="text-text-primary">
|
||||||
{capability.name}
|
{capability.name}
|
||||||
<span className="text-gray-400 text-sm ml-2">
|
<span className="text-text-muted text-sm ml-2">
|
||||||
({capability.technicalName})
|
({capability.technicalName})
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -1013,8 +987,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedMapCollections.length > 0 && (
|
{selectedMapCollections.length > 0 && (
|
||||||
<div className="bg-white rounded-lg border-2 border-desert-stone-light p-6">
|
<div className="bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6">
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-4">
|
<h3 className="text-xl font-semibold text-text-primary mb-4">
|
||||||
Map Collections to Download ({selectedMapCollections.length})
|
Map Collections to Download ({selectedMapCollections.length})
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
|
|
@ -1023,7 +997,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
return (
|
return (
|
||||||
<li key={slug} className="flex items-center">
|
<li key={slug} className="flex items-center">
|
||||||
<IconCheck size={20} className="text-desert-green mr-2" />
|
<IconCheck size={20} className="text-desert-green mr-2" />
|
||||||
<span className="text-gray-700">{collection?.name || slug}</span>
|
<span className="text-text-primary">{collection?.name || slug}</span>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
@ -1032,8 +1006,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedTiers.size > 0 && (
|
{selectedTiers.size > 0 && (
|
||||||
<div className="bg-white rounded-lg border-2 border-desert-stone-light p-6">
|
<div className="bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6">
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-4">
|
<h3 className="text-xl font-semibold text-text-primary mb-4">
|
||||||
Content Categories ({selectedTiers.size})
|
Content Categories ({selectedTiers.size})
|
||||||
</h3>
|
</h3>
|
||||||
{Array.from(selectedTiers.entries()).map(([categorySlug, tier]) => {
|
{Array.from(selectedTiers.entries()).map(([categorySlug, tier]) => {
|
||||||
|
|
@ -1044,16 +1018,16 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
<div key={categorySlug} className="mb-4 last:mb-0">
|
<div key={categorySlug} className="mb-4 last:mb-0">
|
||||||
<div className="flex items-center mb-2">
|
<div className="flex items-center mb-2">
|
||||||
<IconCheck size={20} className="text-desert-green mr-2" />
|
<IconCheck size={20} className="text-desert-green mr-2" />
|
||||||
<span className="text-gray-900 font-medium">
|
<span className="text-text-primary font-medium">
|
||||||
{category.name} - {tier.name}
|
{category.name} - {tier.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-500 text-sm ml-2">
|
<span className="text-text-muted text-sm ml-2">
|
||||||
({resources.length} files)
|
({resources.length} files)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ul className="ml-7 space-y-1">
|
<ul className="ml-7 space-y-1">
|
||||||
{resources.map((resource, idx) => (
|
{resources.map((resource, idx) => (
|
||||||
<li key={idx} className="text-sm text-gray-600">
|
<li key={idx} className="text-sm text-text-secondary">
|
||||||
{resource.title}
|
{resource.title}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
@ -1065,17 +1039,17 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedWikipedia && selectedWikipedia !== 'none' && (
|
{selectedWikipedia && selectedWikipedia !== 'none' && (
|
||||||
<div className="bg-white rounded-lg border-2 border-desert-stone-light p-6">
|
<div className="bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6">
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-4">Wikipedia</h3>
|
<h3 className="text-xl font-semibold text-text-primary mb-4">Wikipedia</h3>
|
||||||
{(() => {
|
{(() => {
|
||||||
const option = wikipediaState?.options.find((o) => o.id === selectedWikipedia)
|
const option = wikipediaState?.options.find((o) => o.id === selectedWikipedia)
|
||||||
return option ? (
|
return option ? (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<IconCheck size={20} className="text-desert-green mr-2" />
|
<IconCheck size={20} className="text-desert-green mr-2" />
|
||||||
<span className="text-gray-700">{option.name}</span>
|
<span className="text-text-primary">{option.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-gray-500 text-sm">
|
<span className="text-text-muted text-sm">
|
||||||
{option.size_mb > 0
|
{option.size_mb > 0
|
||||||
? `${(option.size_mb / 1024).toFixed(1)} GB`
|
? `${(option.size_mb / 1024).toFixed(1)} GB`
|
||||||
: 'No download'}
|
: 'No download'}
|
||||||
|
|
@ -1087,8 +1061,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedAiModels.length > 0 && (
|
{selectedAiModels.length > 0 && (
|
||||||
<div className="bg-white rounded-lg border-2 border-desert-stone-light p-6">
|
<div className="bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6">
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-4">
|
<h3 className="text-xl font-semibold text-text-primary mb-4">
|
||||||
AI Models to Download ({selectedAiModels.length})
|
AI Models to Download ({selectedAiModels.length})
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
|
|
@ -1098,10 +1072,10 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
<li key={modelName} className="flex items-center justify-between">
|
<li key={modelName} className="flex items-center justify-between">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<IconCheck size={20} className="text-desert-green mr-2" />
|
<IconCheck size={20} className="text-desert-green mr-2" />
|
||||||
<span className="text-gray-700">{modelName}</span>
|
<span className="text-text-primary">{modelName}</span>
|
||||||
</div>
|
</div>
|
||||||
{model?.tags?.[0]?.size && (
|
{model?.tags?.[0]?.size && (
|
||||||
<span className="text-gray-500 text-sm">{model.tags[0].size}</span>
|
<span className="text-text-muted text-sm">{model.tags[0].size}</span>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
|
|
@ -1135,7 +1109,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||||
<div className="bg-white rounded-md shadow-md">
|
<div className="bg-surface-primary rounded-md shadow-md">
|
||||||
{renderStepIndicator()}
|
{renderStepIndicator()}
|
||||||
{storageInfo && (
|
{storageInfo && (
|
||||||
<div className="px-6 pt-4">
|
<div className="px-6 pt-4">
|
||||||
|
|
@ -1165,7 +1139,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
</StyledButton>
|
</StyledButton>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-text-secondary">
|
||||||
{(() => {
|
{(() => {
|
||||||
const count = [...CORE_CAPABILITIES, ...ADDITIONAL_TOOLS].filter((cap) =>
|
const count = [...CORE_CAPABILITIES, ...ADDITIONAL_TOOLS].filter((cap) =>
|
||||||
cap.services.some((s) => selectedServices.includes(s))
|
cap.services.some((s) => selectedServices.includes(s))
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ export default function Home(props: {
|
||||||
const { data: easySetupVisited } = useSystemSetting({
|
const { data: easySetupVisited } = useSystemSetting({
|
||||||
key: 'ui.hasVisitedEasySetup'
|
key: 'ui.hasVisitedEasySetup'
|
||||||
})
|
})
|
||||||
const shouldHighlightEasySetup = easySetupVisited?.value ? easySetupVisited?.value !== 'true' : false
|
const shouldHighlightEasySetup = easySetupVisited?.value ? String(easySetupVisited.value) !== 'true' : false
|
||||||
|
|
||||||
// Add installed services (non-dependency services only)
|
// Add installed services (non-dependency services only)
|
||||||
props.system.services
|
props.system.services
|
||||||
|
|
@ -161,7 +161,7 @@ export default function Home(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a key={item.label} href={item.to} target={item.target}>
|
<a key={item.label} href={item.to} target={item.target}>
|
||||||
<div className="relative rounded border-desert-green border-2 bg-desert-green hover:bg-transparent hover:text-black text-white transition-colors shadow-sm h-48 flex flex-col items-center justify-center cursor-pointer text-center px-4">
|
<div className="relative rounded border-desert-green border-2 bg-desert-green hover:bg-transparent hover:text-text-primary text-white transition-colors shadow-sm h-48 flex flex-col items-center justify-center cursor-pointer text-center px-4">
|
||||||
{shouldHighlight && (
|
{shouldHighlight && (
|
||||||
<span className="absolute top-2 right-2 flex items-center justify-center">
|
<span className="absolute top-2 right-2 flex items-center justify-center">
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,10 @@ export default function Maps(props: {
|
||||||
<Head title="Maps" />
|
<Head title="Maps" />
|
||||||
<div className="relative w-full h-screen overflow-hidden">
|
<div className="relative w-full h-screen overflow-hidden">
|
||||||
{/* Nav and alerts are overlayed */}
|
{/* Nav and alerts are overlayed */}
|
||||||
<div className="absolute top-0 left-0 right-0 z-50 flex justify-between p-4 bg-gray-50 backdrop-blur-sm shadow-sm">
|
<div className="absolute top-0 left-0 right-0 z-50 flex justify-between p-4 bg-surface-secondary backdrop-blur-sm shadow-sm">
|
||||||
<Link href="/home" className="flex items-center">
|
<Link href="/home" className="flex items-center">
|
||||||
<IconArrowLeft className="mr-2" size={24} />
|
<IconArrowLeft className="mr-2" size={24} />
|
||||||
<p className="text-lg text-gray-600">Back to Home</p>
|
<p className="text-lg text-text-secondary">Back to Home</p>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/settings/maps" className='mr-4'>
|
<Link href="/settings/maps" className='mr-4'>
|
||||||
<StyledButton variant="primary" icon="IconSettings">
|
<StyledButton variant="primary" icon="IconSettings">
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
|
||||||
confirmVariant="primary"
|
confirmVariant="primary"
|
||||||
icon={<IconDownload className="h-12 w-12 text-desert-green" />}
|
icon={<IconDownload className="h-12 w-12 text-desert-green" />}
|
||||||
>
|
>
|
||||||
<p className="text-gray-700">
|
<p className="text-text-primary">
|
||||||
Are you sure you want to install {service.friendly_name || service.service_name}? This
|
Are you sure you want to install {service.friendly_name || service.service_name}? This
|
||||||
will start the service and make it available in your Project N.O.M.A.D. instance. It may
|
will start the service and make it available in your Project N.O.M.A.D. instance. It may
|
||||||
take some time to complete.
|
take some time to complete.
|
||||||
|
|
@ -214,7 +214,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
|
||||||
confirmText={'Force Reinstall'}
|
confirmText={'Force Reinstall'}
|
||||||
cancelText="Cancel"
|
cancelText="Cancel"
|
||||||
>
|
>
|
||||||
<p className="text-gray-700">
|
<p className="text-text-primary">
|
||||||
Are you sure you want to force reinstall {record.service_name}? This will{' '}
|
Are you sure you want to force reinstall {record.service_name}? This will{' '}
|
||||||
<strong>WIPE ALL DATA</strong> for this service and cannot be undone. You should
|
<strong>WIPE ALL DATA</strong> for this service and cannot be undone. You should
|
||||||
only do this if the service is malfunctioning and other troubleshooting steps have
|
only do this if the service is malfunctioning and other troubleshooting steps have
|
||||||
|
|
@ -285,7 +285,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
|
||||||
confirmText={record.status === 'running' ? 'Stop' : 'Start'}
|
confirmText={record.status === 'running' ? 'Stop' : 'Start'}
|
||||||
cancelText="Cancel"
|
cancelText="Cancel"
|
||||||
>
|
>
|
||||||
<p className="text-gray-700">
|
<p className="text-text-primary">
|
||||||
Are you sure you want to {record.status === 'running' ? 'stop' : 'start'}{' '}
|
Are you sure you want to {record.status === 'running' ? 'stop' : 'start'}{' '}
|
||||||
{record.service_name}?
|
{record.service_name}?
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -311,7 +311,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
|
||||||
confirmText={'Restart'}
|
confirmText={'Restart'}
|
||||||
cancelText="Cancel"
|
cancelText="Cancel"
|
||||||
>
|
>
|
||||||
<p className="text-gray-700">
|
<p className="text-text-primary">
|
||||||
Are you sure you want to restart {record.service_name}?
|
Are you sure you want to restart {record.service_name}?
|
||||||
</p>
|
</p>
|
||||||
</StyledModal>,
|
</StyledModal>,
|
||||||
|
|
@ -338,7 +338,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-semibold">Apps</h1>
|
<h1 className="text-4xl font-semibold">Apps</h1>
|
||||||
<p className="text-gray-500 mt-1">
|
<p className="text-text-muted mt-1">
|
||||||
Manage the applications that are available in your Project N.O.M.A.D. instance. Nightly update checks will automatically detect when new versions of these apps are available.
|
Manage the applications that are available in your Project N.O.M.A.D. instance. Nightly update checks will automatically detect when new versions of these apps are available.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -364,7 +364,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<p>{record.friendly_name || record.service_name}</p>
|
<p>{record.friendly_name || record.service_name}</p>
|
||||||
<p className="text-sm text-gray-500">{record.description}</p>
|
<p className="text-sm text-text-muted">{record.description}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -398,7 +398,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
|
||||||
if (record.available_update_version) {
|
if (record.available_update_version) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-gray-500">{currentTag}</span>
|
<span className="text-text-muted">{currentTag}</span>
|
||||||
<IconArrowUp className="h-4 w-4 text-desert-green" />
|
<IconArrowUp className="h-4 w-4 text-desert-green" />
|
||||||
<span className="text-desert-green font-semibold">
|
<span className="text-desert-green font-semibold">
|
||||||
{record.available_update_version}
|
{record.available_update_version}
|
||||||
|
|
@ -406,7 +406,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return <span className="text-gray-600">{currentTag}</span>
|
return <span className="text-text-secondary">{currentTag}</span>
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Head, Link, usePage } from '@inertiajs/react'
|
import { Head, Link, usePage } from '@inertiajs/react'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import SettingsLayout from '~/layouts/SettingsLayout'
|
import SettingsLayout from '~/layouts/SettingsLayout'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import CircularGauge from '~/components/systeminfo/CircularGauge'
|
import CircularGauge from '~/components/systeminfo/CircularGauge'
|
||||||
|
|
@ -40,6 +40,7 @@ export default function BenchmarkPage(props: {
|
||||||
const aiInstalled = useServiceInstalledStatus(SERVICE_NAMES.OLLAMA)
|
const aiInstalled = useServiceInstalledStatus(SERVICE_NAMES.OLLAMA)
|
||||||
const [progress, setProgress] = useState<BenchmarkProgressWithID | null>(null)
|
const [progress, setProgress] = useState<BenchmarkProgressWithID | null>(null)
|
||||||
const [isRunning, setIsRunning] = useState(props.benchmark.status !== 'idle')
|
const [isRunning, setIsRunning] = useState(props.benchmark.status !== 'idle')
|
||||||
|
const refetchLatestRef = useRef<(() => void) | null>(null)
|
||||||
const [showDetails, setShowDetails] = useState(false)
|
const [showDetails, setShowDetails] = useState(false)
|
||||||
const [showHistory, setShowHistory] = useState(false)
|
const [showHistory, setShowHistory] = useState(false)
|
||||||
const [showAIRequiredAlert, setShowAIRequiredAlert] = useState(false)
|
const [showAIRequiredAlert, setShowAIRequiredAlert] = useState(false)
|
||||||
|
|
@ -60,6 +61,7 @@ export default function BenchmarkPage(props: {
|
||||||
},
|
},
|
||||||
initialData: props.benchmark.latestResult,
|
initialData: props.benchmark.latestResult,
|
||||||
})
|
})
|
||||||
|
refetchLatestRef.current = refetchLatest
|
||||||
|
|
||||||
// Fetch all benchmark results for history
|
// Fetch all benchmark results for history
|
||||||
const { data: benchmarkHistory } = useQuery({
|
const { data: benchmarkHistory } = useQuery({
|
||||||
|
|
@ -306,14 +308,15 @@ export default function BenchmarkPage(props: {
|
||||||
setProgress(data)
|
setProgress(data)
|
||||||
if (data.status === 'completed' || data.status === 'error') {
|
if (data.status === 'completed' || data.status === 'error') {
|
||||||
setIsRunning(false)
|
setIsRunning(false)
|
||||||
refetchLatest()
|
refetchLatestRef.current?.()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe()
|
unsubscribe()
|
||||||
}
|
}
|
||||||
}, [subscribe, refetchLatest])
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [subscribe])
|
||||||
|
|
||||||
const formatBytes = (bytes: number) => {
|
const formatBytes = (bytes: number) => {
|
||||||
const gb = bytes / (1024 * 1024 * 1024)
|
const gb = bytes / (1024 * 1024 * 1024)
|
||||||
|
|
|
||||||
|
|
@ -12,16 +12,16 @@ export default function LegalPage() {
|
||||||
{/* License Agreement */}
|
{/* License Agreement */}
|
||||||
<section className="mb-10">
|
<section className="mb-10">
|
||||||
<h2 className="text-2xl font-semibold mb-4">License Agreement</h2>
|
<h2 className="text-2xl font-semibold mb-4">License Agreement</h2>
|
||||||
<p className="text-gray-700 mb-3">Copyright 2024-2026 Crosstalk Solutions, LLC</p>
|
<p className="text-text-primary mb-3">Copyright 2024-2026 Crosstalk Solutions, LLC</p>
|
||||||
<p className="text-gray-700 mb-3">
|
<p className="text-text-primary mb-3">
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
You may obtain a copy of the License at
|
You may obtain a copy of the License at
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-700 mb-3">
|
<p className="text-text-primary mb-3">
|
||||||
<a href="https://www.apache.org/licenses/LICENSE-2.0" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">https://www.apache.org/licenses/LICENSE-2.0</a>
|
<a href="https://www.apache.org/licenses/LICENSE-2.0" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">https://www.apache.org/licenses/LICENSE-2.0</a>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-700">
|
<p className="text-text-primary">
|
||||||
Unless required by applicable law or agreed to in writing, software
|
Unless required by applicable law or agreed to in writing, software
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
|
@ -33,11 +33,11 @@ export default function LegalPage() {
|
||||||
{/* Third-Party Software */}
|
{/* Third-Party Software */}
|
||||||
<section className="mb-10">
|
<section className="mb-10">
|
||||||
<h2 className="text-2xl font-semibold mb-4">Third-Party Software Attribution</h2>
|
<h2 className="text-2xl font-semibold mb-4">Third-Party Software Attribution</h2>
|
||||||
<p className="text-gray-700 mb-4">
|
<p className="text-text-primary mb-4">
|
||||||
Project N.O.M.A.D. integrates the following open source projects. We are grateful to
|
Project N.O.M.A.D. integrates the following open source projects. We are grateful to
|
||||||
their developers and communities:
|
their developers and communities:
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-3 text-gray-700">
|
<ul className="space-y-3 text-text-primary">
|
||||||
<li>
|
<li>
|
||||||
<strong>Kiwix</strong> - Offline Wikipedia and content reader (GPL-3.0 License)
|
<strong>Kiwix</strong> - Offline Wikipedia and content reader (GPL-3.0 License)
|
||||||
<br />
|
<br />
|
||||||
|
|
@ -74,10 +74,10 @@ export default function LegalPage() {
|
||||||
{/* Privacy Statement */}
|
{/* Privacy Statement */}
|
||||||
<section className="mb-10">
|
<section className="mb-10">
|
||||||
<h2 className="text-2xl font-semibold mb-4">Privacy Statement</h2>
|
<h2 className="text-2xl font-semibold mb-4">Privacy Statement</h2>
|
||||||
<p className="text-gray-700 mb-3">
|
<p className="text-text-primary mb-3">
|
||||||
Project N.O.M.A.D. is designed with privacy as a core principle:
|
Project N.O.M.A.D. is designed with privacy as a core principle:
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc list-inside space-y-2 text-gray-700">
|
<ul className="list-disc list-inside space-y-2 text-text-primary">
|
||||||
<li><strong>Zero Telemetry:</strong> N.O.M.A.D. does not collect, transmit, or store any usage data, analytics, or telemetry.</li>
|
<li><strong>Zero Telemetry:</strong> N.O.M.A.D. does not collect, transmit, or store any usage data, analytics, or telemetry.</li>
|
||||||
<li><strong>Local-First:</strong> All your data, downloaded content, AI conversations, and notes remain on your device.</li>
|
<li><strong>Local-First:</strong> All your data, downloaded content, AI conversations, and notes remain on your device.</li>
|
||||||
<li><strong>No Accounts Required:</strong> N.O.M.A.D. operates without user accounts or authentication by default.</li>
|
<li><strong>No Accounts Required:</strong> N.O.M.A.D. operates without user accounts or authentication by default.</li>
|
||||||
|
|
@ -88,17 +88,17 @@ export default function LegalPage() {
|
||||||
{/* Content Disclaimer */}
|
{/* Content Disclaimer */}
|
||||||
<section className="mb-10">
|
<section className="mb-10">
|
||||||
<h2 className="text-2xl font-semibold mb-4">Content Disclaimer</h2>
|
<h2 className="text-2xl font-semibold mb-4">Content Disclaimer</h2>
|
||||||
<p className="text-gray-700 mb-3">
|
<p className="text-text-primary mb-3">
|
||||||
Project N.O.M.A.D. provides tools to download and access content from third-party sources
|
Project N.O.M.A.D. provides tools to download and access content from third-party sources
|
||||||
including Wikipedia, Wikibooks, medical references, educational platforms, and other
|
including Wikipedia, Wikibooks, medical references, educational platforms, and other
|
||||||
publicly available resources.
|
publicly available resources.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-700 mb-3">
|
<p className="text-text-primary mb-3">
|
||||||
Crosstalk Solutions, LLC does not create, control, verify, or guarantee the accuracy,
|
Crosstalk Solutions, LLC does not create, control, verify, or guarantee the accuracy,
|
||||||
completeness, or reliability of any third-party content. The inclusion of any content
|
completeness, or reliability of any third-party content. The inclusion of any content
|
||||||
does not constitute an endorsement.
|
does not constitute an endorsement.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-700">
|
<p className="text-text-primary">
|
||||||
Users are responsible for evaluating the appropriateness and accuracy of any content
|
Users are responsible for evaluating the appropriateness and accuracy of any content
|
||||||
they download and use.
|
they download and use.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -107,15 +107,15 @@ export default function LegalPage() {
|
||||||
{/* Medical Disclaimer */}
|
{/* Medical Disclaimer */}
|
||||||
<section className="mb-10">
|
<section className="mb-10">
|
||||||
<h2 className="text-2xl font-semibold mb-4">Medical and Emergency Information Disclaimer</h2>
|
<h2 className="text-2xl font-semibold mb-4">Medical and Emergency Information Disclaimer</h2>
|
||||||
<p className="text-gray-700 mb-3">
|
<p className="text-text-primary mb-3">
|
||||||
Some content available through N.O.M.A.D. includes medical references, first aid guides,
|
Some content available through N.O.M.A.D. includes medical references, first aid guides,
|
||||||
and emergency preparedness information. This content is provided for general
|
and emergency preparedness information. This content is provided for general
|
||||||
informational purposes only.
|
informational purposes only.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-700 mb-3 font-semibold">
|
<p className="text-text-primary mb-3 font-semibold">
|
||||||
This information is NOT a substitute for professional medical advice, diagnosis, or treatment.
|
This information is NOT a substitute for professional medical advice, diagnosis, or treatment.
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc list-inside space-y-2 text-gray-700 mb-3">
|
<ul className="list-disc list-inside space-y-2 text-text-primary mb-3">
|
||||||
<li>Always seek the advice of qualified health providers with questions about medical conditions.</li>
|
<li>Always seek the advice of qualified health providers with questions about medical conditions.</li>
|
||||||
<li>Never disregard professional medical advice or delay seeking it because of something you read in offline content.</li>
|
<li>Never disregard professional medical advice or delay seeking it because of something you read in offline content.</li>
|
||||||
<li>In a medical emergency, call emergency services immediately if available.</li>
|
<li>In a medical emergency, call emergency services immediately if available.</li>
|
||||||
|
|
@ -126,15 +126,15 @@ export default function LegalPage() {
|
||||||
{/* Data Storage Notice */}
|
{/* Data Storage Notice */}
|
||||||
<section className="mb-10">
|
<section className="mb-10">
|
||||||
<h2 className="text-2xl font-semibold mb-4">Data Storage</h2>
|
<h2 className="text-2xl font-semibold mb-4">Data Storage</h2>
|
||||||
<p className="text-gray-700 mb-3">
|
<p className="text-text-primary mb-3">
|
||||||
All data associated with Project N.O.M.A.D. is stored locally on your device:
|
All data associated with Project N.O.M.A.D. is stored locally on your device:
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc list-inside space-y-2 text-gray-700">
|
<ul className="list-disc list-inside space-y-2 text-text-primary">
|
||||||
<li><strong>Installation Directory:</strong> /opt/project-nomad</li>
|
<li><strong>Installation Directory:</strong> /opt/project-nomad</li>
|
||||||
<li><strong>Downloaded Content:</strong> /opt/project-nomad/storage</li>
|
<li><strong>Downloaded Content:</strong> /opt/project-nomad/storage</li>
|
||||||
<li><strong>Application Data:</strong> Stored in Docker volumes on your local system</li>
|
<li><strong>Application Data:</strong> Stored in Docker volumes on your local system</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-700 mt-3">
|
<p className="text-text-primary mt-3">
|
||||||
You maintain full control over your data. Uninstalling N.O.M.A.D. or deleting these
|
You maintain full control over your data. Uninstalling N.O.M.A.D. or deleting these
|
||||||
directories will permanently remove all associated data.
|
directories will permanently remove all associated data.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ export default function MapsManager(props: {
|
||||||
cancelText="Cancel"
|
cancelText="Cancel"
|
||||||
confirmVariant="danger"
|
confirmVariant="danger"
|
||||||
>
|
>
|
||||||
<p className="text-gray-700">
|
<p className="text-text-secondary">
|
||||||
Are you sure you want to delete {file.name}? This action cannot be undone.
|
Are you sure you want to delete {file.name}? This action cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
</StyledModal>,
|
</StyledModal>,
|
||||||
|
|
@ -136,7 +136,7 @@ export default function MapsManager(props: {
|
||||||
cancelText="Cancel"
|
cancelText="Cancel"
|
||||||
confirmVariant="primary"
|
confirmVariant="primary"
|
||||||
>
|
>
|
||||||
<p className="text-gray-700">
|
<p className="text-text-secondary">
|
||||||
Are you sure you want to download <strong>{isCollection ? record.name : record}</strong>?
|
Are you sure you want to download <strong>{isCollection ? record.name : record}</strong>?
|
||||||
It may take some time for it to be available depending on the file size and your internet
|
It may take some time for it to be available depending on the file size and your internet
|
||||||
connection.
|
connection.
|
||||||
|
|
@ -180,7 +180,7 @@ export default function MapsManager(props: {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<h1 className="text-4xl font-semibold mb-2">Maps Manager</h1>
|
<h1 className="text-4xl font-semibold mb-2">Maps Manager</h1>
|
||||||
<p className="text-gray-500">Manage your stored map files and explore new regions!</p>
|
<p className="text-text-muted">Manage your stored map files and explore new regions!</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-4">
|
<div className="flex space-x-4">
|
||||||
|
|
||||||
|
|
@ -220,7 +220,7 @@ export default function MapsManager(props: {
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{curatedCollections && curatedCollections.length === 0 && (
|
{curatedCollections && curatedCollections.length === 0 && (
|
||||||
<p className="text-gray-500">No curated collections available.</p>
|
<p className="text-text-muted">No curated collections available.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-12 mb-6 flex items-center justify-between">
|
<div className="mt-12 mb-6 flex items-center justify-between">
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ export default function ModelsPage(props: {
|
||||||
confirmText="Reinstall"
|
confirmText="Reinstall"
|
||||||
cancelText="Cancel"
|
cancelText="Cancel"
|
||||||
>
|
>
|
||||||
<p className="text-gray-700">
|
<p className="text-text-primary">
|
||||||
This will recreate the {aiAssistantName} container with GPU support enabled.
|
This will recreate the {aiAssistantName} container with GPU support enabled.
|
||||||
Your downloaded models will be preserved. The service will be briefly
|
Your downloaded models will be preserved. The service will be briefly
|
||||||
unavailable during reinstall.
|
unavailable during reinstall.
|
||||||
|
|
@ -190,7 +190,7 @@ export default function ModelsPage(props: {
|
||||||
cancelText="Cancel"
|
cancelText="Cancel"
|
||||||
confirmVariant="primary"
|
confirmVariant="primary"
|
||||||
>
|
>
|
||||||
<p className="text-gray-700">
|
<p className="text-text-primary">
|
||||||
Are you sure you want to delete this model? You will need to download it again if you want
|
Are you sure you want to delete this model? You will need to download it again if you want
|
||||||
to use it in the future.
|
to use it in the future.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -224,7 +224,7 @@ export default function ModelsPage(props: {
|
||||||
<div className="xl:pl-72 w-full">
|
<div className="xl:pl-72 w-full">
|
||||||
<main className="px-12 py-6">
|
<main className="px-12 py-6">
|
||||||
<h1 className="text-4xl font-semibold mb-4">{aiAssistantName}</h1>
|
<h1 className="text-4xl font-semibold mb-4">{aiAssistantName}</h1>
|
||||||
<p className="text-gray-500 mb-4">
|
<p className="text-text-muted mb-4">
|
||||||
Easily manage the {aiAssistantName}'s settings and installed models. We recommend
|
Easily manage the {aiAssistantName}'s settings and installed models. We recommend
|
||||||
starting with smaller models first to see how they perform on your system before moving
|
starting with smaller models first to see how they perform on your system before moving
|
||||||
on to larger ones.
|
on to larger ones.
|
||||||
|
|
@ -259,7 +259,7 @@ export default function ModelsPage(props: {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<StyledSectionHeader title="Settings" className="mt-8 mb-4" />
|
<StyledSectionHeader title="Settings" className="mt-8 mb-4" />
|
||||||
<div className="bg-white rounded-lg border-2 border-gray-200 p-6">
|
<div className="bg-surface-primary rounded-lg border-2 border-border-subtle p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Switch
|
<Switch
|
||||||
checked={chatSuggestionsEnabled}
|
checked={chatSuggestionsEnabled}
|
||||||
|
|
@ -300,7 +300,7 @@ export default function ModelsPage(props: {
|
||||||
debouncedSetQuery(e.target.value)
|
debouncedSetQuery(e.target.value)
|
||||||
}}
|
}}
|
||||||
className="w-1/3"
|
className="w-1/3"
|
||||||
leftIcon={<IconSearch className="w-5 h-5 text-gray-400" />}
|
leftIcon={<IconSearch className="w-5 h-5 text-text-muted" />}
|
||||||
/>
|
/>
|
||||||
<StyledButton
|
<StyledButton
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|
@ -323,7 +323,7 @@ export default function ModelsPage(props: {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<p className="text-lg font-semibold">{record.name}</p>
|
<p className="text-lg font-semibold">{record.name}</p>
|
||||||
<p className="text-sm text-gray-500">{record.description}</p>
|
<p className="text-sm text-text-muted">{record.description}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -342,49 +342,49 @@ export default function ModelsPage(props: {
|
||||||
expandable={{
|
expandable={{
|
||||||
expandedRowRender: (record) => (
|
expandedRowRender: (record) => (
|
||||||
<div className="pl-14">
|
<div className="pl-14">
|
||||||
<div className="bg-white overflow-hidden">
|
<div className="bg-surface-primary overflow-hidden">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-border-subtle">
|
||||||
<thead className="bg-white">
|
<thead className="bg-surface-primary">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||||
Tag
|
Tag
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||||
Input Type
|
Input Type
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||||
Context Size
|
Context Size
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||||
Model Size
|
Model Size
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||||
Action
|
Action
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-surface-primary divide-y divide-border-subtle">
|
||||||
{record.tags.map((tag, tagIndex) => {
|
{record.tags.map((tag, tagIndex) => {
|
||||||
const isInstalled = props.models.installedModels.some(
|
const isInstalled = props.models.installedModels.some(
|
||||||
(mod) => mod.name === tag.name
|
(mod) => mod.name === tag.name
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<tr key={tagIndex} className="hover:bg-slate-50">
|
<tr key={tagIndex} className="hover:bg-surface-secondary">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className="text-sm font-medium text-gray-900">
|
<span className="text-sm font-medium text-text-primary">
|
||||||
{tag.name}
|
{tag.name}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className="text-sm text-gray-600">{tag.input || 'N/A'}</span>
|
<span className="text-sm text-text-secondary">{tag.input || 'N/A'}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className="text-sm text-gray-600">
|
<span className="text-sm text-text-secondary">
|
||||||
{tag.context || 'N/A'}
|
{tag.context || 'N/A'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className="text-sm text-gray-600">{tag.size || 'N/A'}</span>
|
<span className="text-sm text-text-secondary">{tag.size || 'N/A'}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<StyledButton
|
<StyledButton
|
||||||
|
|
|
||||||
110
admin/inertia/pages/settings/support.tsx
Normal file
110
admin/inertia/pages/settings/support.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { Head } from '@inertiajs/react'
|
||||||
|
import { IconExternalLink } from '@tabler/icons-react'
|
||||||
|
import SettingsLayout from '~/layouts/SettingsLayout'
|
||||||
|
|
||||||
|
export default function SupportPage() {
|
||||||
|
return (
|
||||||
|
<SettingsLayout>
|
||||||
|
<Head title="Support the Project | Project N.O.M.A.D." />
|
||||||
|
<div className="xl:pl-72 w-full">
|
||||||
|
<main className="px-12 py-6 max-w-4xl">
|
||||||
|
<h1 className="text-4xl font-semibold mb-4">Support the Project</h1>
|
||||||
|
<p className="text-text-muted mb-10 text-lg">
|
||||||
|
Project NOMAD is 100% free and open source — no subscriptions, no paywalls, no catch.
|
||||||
|
If you'd like to help keep the project going, here are a few ways to show your support.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Ko-fi */}
|
||||||
|
<section className="mb-12">
|
||||||
|
<h2 className="text-2xl font-semibold mb-3">Buy Us a Coffee</h2>
|
||||||
|
<p className="text-text-muted mb-4">
|
||||||
|
Every contribution helps fund development, server costs, and new content packs for NOMAD.
|
||||||
|
Even a small donation goes a long way.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://ko-fi.com/crosstalk"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2.5 bg-[#FF5E5B] hover:bg-[#e54e4b] text-white font-semibold rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Support on Ko-fi
|
||||||
|
<IconExternalLink size={18} />
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Rogue Support */}
|
||||||
|
<section className="mb-12">
|
||||||
|
<h2 className="text-2xl font-semibold mb-3">Need Help With Your Home Network?</h2>
|
||||||
|
<a
|
||||||
|
href="https://rogue.support"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block mb-4 rounded-lg overflow-hidden hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/rogue-support-banner.png"
|
||||||
|
alt="Rogue Support — Conquer Your Home Network"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<p className="text-text-muted mb-4">
|
||||||
|
Rogue Support is a networking consultation service for home users.
|
||||||
|
Think of it as Uber for computer networking — expert help when you need it.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://rogue.support"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 text-blue-600 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
Visit Rogue.Support
|
||||||
|
<IconExternalLink size={16} />
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Other Ways to Help */}
|
||||||
|
<section className="mb-10">
|
||||||
|
<h2 className="text-2xl font-semibold mb-3">Other Ways to Help</h2>
|
||||||
|
<ul className="space-y-2 text-text-muted">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://github.com/Crosstalk-Solutions/project-nomad"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
Star the project on GitHub
|
||||||
|
</a>
|
||||||
|
{' '}— it helps more people discover NOMAD
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://github.com/Crosstalk-Solutions/project-nomad/issues"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
Report bugs and suggest features
|
||||||
|
</a>
|
||||||
|
{' '}— every report makes NOMAD better
|
||||||
|
</li>
|
||||||
|
<li>Share NOMAD with someone who'd use it — word of mouth is the best marketing</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://discord.com/invite/crosstalksolutions"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
Join the Discord community
|
||||||
|
</a>
|
||||||
|
{' '}— hang out, share your build, help other users
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</SettingsLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { Head } from '@inertiajs/react'
|
||||||
import SettingsLayout from '~/layouts/SettingsLayout'
|
import SettingsLayout from '~/layouts/SettingsLayout'
|
||||||
import { SystemInformationResponse } from '../../../types/system'
|
import { SystemInformationResponse } from '../../../types/system'
|
||||||
import { formatBytes } from '~/lib/util'
|
import { formatBytes } from '~/lib/util'
|
||||||
|
import { getAllDiskDisplayItems } from '~/hooks/useDiskDisplayData'
|
||||||
import CircularGauge from '~/components/systeminfo/CircularGauge'
|
import CircularGauge from '~/components/systeminfo/CircularGauge'
|
||||||
import HorizontalBarChart from '~/components/HorizontalBarChart'
|
import HorizontalBarChart from '~/components/HorizontalBarChart'
|
||||||
import InfoCard from '~/components/systeminfo/InfoCard'
|
import InfoCard from '~/components/systeminfo/InfoCard'
|
||||||
|
|
@ -71,7 +72,7 @@ export default function SettingsPage(props: {
|
||||||
confirmText="Reinstall"
|
confirmText="Reinstall"
|
||||||
cancelText="Cancel"
|
cancelText="Cancel"
|
||||||
>
|
>
|
||||||
<p className="text-gray-700">
|
<p className="text-text-primary">
|
||||||
This will recreate the AI Assistant container with GPU support enabled.
|
This will recreate the AI Assistant container with GPU support enabled.
|
||||||
Your downloaded models will be preserved. The service will be briefly
|
Your downloaded models will be preserved. The service will be briefly
|
||||||
unavailable during reinstall.
|
unavailable during reinstall.
|
||||||
|
|
@ -105,42 +106,7 @@ export default function SettingsPage(props: {
|
||||||
: `${uptimeMinutes}m`
|
: `${uptimeMinutes}m`
|
||||||
|
|
||||||
// Build storage display items - fall back to fsSize when disk array is empty
|
// Build storage display items - fall back to fsSize when disk array is empty
|
||||||
// (Same approach as Easy Setup wizard fix from PR #90)
|
const storageItems = getAllDiskDisplayItems(info?.disk, info?.fsSize)
|
||||||
const validDisks = info?.disk?.filter((d) => d.totalSize > 0) || []
|
|
||||||
let storageItems: {
|
|
||||||
label: string
|
|
||||||
value: number
|
|
||||||
total: string
|
|
||||||
used: string
|
|
||||||
subtext: string
|
|
||||||
}[] = []
|
|
||||||
if (validDisks.length > 0) {
|
|
||||||
storageItems = validDisks.map((disk) => ({
|
|
||||||
label: disk.name || 'Unknown',
|
|
||||||
value: disk.percentUsed || 0,
|
|
||||||
total: disk.totalSize ? formatBytes(disk.totalSize) : 'N/A',
|
|
||||||
used: disk.totalUsed ? formatBytes(disk.totalUsed) : 'N/A',
|
|
||||||
subtext: `${formatBytes(disk.totalUsed || 0)} / ${formatBytes(disk.totalSize || 0)}`,
|
|
||||||
}))
|
|
||||||
} else if (info?.fsSize && info.fsSize.length > 0) {
|
|
||||||
// Deduplicate by size (same physical disk mounted in multiple places shows identical sizes)
|
|
||||||
const seen = new Set<number>()
|
|
||||||
const uniqueFs = info.fsSize.filter((fs) => {
|
|
||||||
if (fs.size <= 0 || seen.has(fs.size)) return false
|
|
||||||
seen.add(fs.size)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
// Prefer real block devices (/dev/), exclude virtual filesystems (efivarfs, tmpfs, etc.)
|
|
||||||
const realDevices = uniqueFs.filter((fs) => fs.fs.startsWith('/dev/'))
|
|
||||||
const displayFs = realDevices.length > 0 ? realDevices : uniqueFs
|
|
||||||
storageItems = displayFs.map((fs) => ({
|
|
||||||
label: fs.fs || 'Unknown',
|
|
||||||
value: fs.use || 0,
|
|
||||||
total: formatBytes(fs.size),
|
|
||||||
used: formatBytes(fs.used),
|
|
||||||
subtext: `${formatBytes(fs.used)} / ${formatBytes(fs.size)}`,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsLayout>
|
<SettingsLayout>
|
||||||
|
|
@ -313,7 +279,7 @@ export default function SettingsPage(props: {
|
||||||
style={{ width: `${memoryUsagePercent}%` }}
|
style={{ width: `${memoryUsagePercent}%` }}
|
||||||
></div>
|
></div>
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<span className="text-sm font-bold text-desert-white drop-shadow-md z-10">
|
<span className="text-sm font-bold text-white drop-shadow-md z-10">
|
||||||
{memoryUsagePercent}% Utilized
|
{memoryUsagePercent}% Utilized
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ function ContentUpdatesSection() {
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<StyledSectionHeader title="Content Updates" />
|
<StyledSectionHeader title="Content Updates" />
|
||||||
|
|
||||||
<div className="bg-white rounded-lg border shadow-md overflow-hidden p-6">
|
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-desert-stone-dark">
|
<p className="text-desert-stone-dark">
|
||||||
Check if newer versions of your installed ZIM files and maps are available.
|
Check if newer versions of your installed ZIM files and maps are available.
|
||||||
|
|
@ -258,7 +258,13 @@ export default function SystemUpdatePage(props: { system: Props }) {
|
||||||
|
|
||||||
// Check if update is complete or errored
|
// Check if update is complete or errored
|
||||||
if (response.stage === 'complete') {
|
if (response.stage === 'complete') {
|
||||||
// Give a moment for the new container to fully start
|
// Re-check version so the KV store clears the stale "update available" flag
|
||||||
|
// before we reload, otherwise the banner shows "current → current"
|
||||||
|
try {
|
||||||
|
await api.checkLatestVersion(true)
|
||||||
|
} catch {
|
||||||
|
// Non-critical - page reload will still work
|
||||||
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
}, 2000)
|
}, 2000)
|
||||||
|
|
@ -431,7 +437,7 @@ export default function SystemUpdatePage(props: { system: Props }) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="bg-white rounded-lg border shadow-md overflow-hidden">
|
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden">
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
<div className="flex justify-center mb-4">{getStatusIcon()}</div>
|
<div className="flex justify-center mb-4">{getStatusIcon()}</div>
|
||||||
|
|
||||||
|
|
@ -526,7 +532,7 @@ export default function SystemUpdatePage(props: { system: Props }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t bg-white p-6">
|
<div className="border-t bg-surface-primary p-6">
|
||||||
<h3 className="text-lg font-semibold text-desert-green mb-4">
|
<h3 className="text-lg font-semibold text-desert-green mb-4">
|
||||||
What happens during an update?
|
What happens during an update?
|
||||||
</h3>
|
</h3>
|
||||||
|
|
@ -596,7 +602,7 @@ export default function SystemUpdatePage(props: { system: Props }) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<StyledSectionHeader title="Early Access" className="mt-8" />
|
<StyledSectionHeader title="Early Access" className="mt-8" />
|
||||||
<div className="bg-white rounded-lg border shadow-md overflow-hidden mt-6 p-6">
|
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden mt-6 p-6">
|
||||||
<Switch
|
<Switch
|
||||||
checked={earlyAccessSetting.data?.value || false}
|
checked={earlyAccessSetting.data?.value || false}
|
||||||
onChange={(newVal) => {
|
onChange={(newVal) => {
|
||||||
|
|
@ -608,7 +614,7 @@ export default function SystemUpdatePage(props: { system: Props }) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ContentUpdatesSection />
|
<ContentUpdatesSection />
|
||||||
<div className="bg-white rounded-lg border shadow-md overflow-hidden py-6 mt-12">
|
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden py-6 mt-12">
|
||||||
<div className="flex flex-col md:flex-row justify-between items-center p-8 gap-y-8 md:gap-y-0 gap-x-8">
|
<div className="flex flex-col md:flex-row justify-between items-center p-8 gap-y-8 md:gap-y-0 gap-x-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="max-w-xl text-lg font-bold text-desert-green sm:text-xl lg:col-span-7">
|
<h2 className="max-w-xl text-lg font-bold text-desert-green sm:text-xl lg:col-span-7">
|
||||||
|
|
@ -648,7 +654,7 @@ export default function SystemUpdatePage(props: { system: Props }) {
|
||||||
|
|
||||||
{showLogs && (
|
{showLogs && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||||
<div className="bg-white rounded-lg shadow-2xl max-w-4xl w-full max-h-[80vh] flex flex-col">
|
<div className="bg-surface-primary rounded-lg shadow-2xl max-w-4xl w-full max-h-[80vh] flex flex-col">
|
||||||
<div className="p-6 border-b border-desert-stone-light flex justify-between items-center">
|
<div className="p-6 border-b border-desert-stone-light flex justify-between items-center">
|
||||||
<h3 className="text-xl font-bold text-desert-green">Update Logs</h3>
|
<h3 className="text-xl font-bold text-desert-green">Update Logs</h3>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ export default function ZimPage() {
|
||||||
cancelText="Cancel"
|
cancelText="Cancel"
|
||||||
confirmVariant="danger"
|
confirmVariant="danger"
|
||||||
>
|
>
|
||||||
<p className="text-gray-700">
|
<p className="text-text-secondary">
|
||||||
Are you sure you want to delete {file.name}? This action cannot be undone.
|
Are you sure you want to delete {file.name}? This action cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
</StyledModal>,
|
</StyledModal>,
|
||||||
|
|
@ -62,7 +62,7 @@ export default function ZimPage() {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<h1 className="text-4xl font-semibold mb-2">Content Manager</h1>
|
<h1 className="text-4xl font-semibold mb-2">Content Manager</h1>
|
||||||
<p className="text-gray-500">
|
<p className="text-text-muted">
|
||||||
Manage your stored content files.
|
Manage your stored content files.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -94,7 +94,7 @@ export default function ZimPage() {
|
||||||
accessor: 'summary',
|
accessor: 'summary',
|
||||||
title: 'Summary',
|
title: 'Summary',
|
||||||
render: (record) => (
|
render: (record) => (
|
||||||
<span className="text-gray-600 text-sm line-clamp-2">
|
<span className="text-text-secondary text-sm line-clamp-2">
|
||||||
{record.summary || '—'}
|
{record.summary || '—'}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,7 @@ export default function ZimRemoteExplorer() {
|
||||||
cancelText="Cancel"
|
cancelText="Cancel"
|
||||||
confirmVariant="primary"
|
confirmVariant="primary"
|
||||||
>
|
>
|
||||||
<p className="text-gray-700">
|
<p className="text-text-primary">
|
||||||
Are you sure you want to download{' '}
|
Are you sure you want to download{' '}
|
||||||
<strong>{record.title}</strong>? It may take some time for it
|
<strong>{record.title}</strong>? It may take some time for it
|
||||||
to be available depending on the file size and your internet connection. The Kiwix
|
to be available depending on the file size and your internet connection. The Kiwix
|
||||||
|
|
@ -277,7 +277,7 @@ export default function ZimRemoteExplorer() {
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<h1 className="text-4xl font-semibold mb-2">Content Explorer</h1>
|
<h1 className="text-4xl font-semibold mb-2">Content Explorer</h1>
|
||||||
<p className="text-gray-500">Browse and download content for offline reading!</p>
|
<p className="text-text-muted">Browse and download content for offline reading!</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!isOnline && (
|
{!isOnline && (
|
||||||
|
|
@ -310,13 +310,13 @@ export default function ZimRemoteExplorer() {
|
||||||
|
|
||||||
{/* Wikipedia Selector */}
|
{/* Wikipedia Selector */}
|
||||||
{isLoadingWikipedia ? (
|
{isLoadingWikipedia ? (
|
||||||
<div className="mt-8 bg-white rounded-lg border border-gray-200 p-6">
|
<div className="mt-8 bg-surface-primary rounded-lg border border-border-subtle p-6">
|
||||||
<div className="flex justify-center py-6">
|
<div className="flex justify-center py-6">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-desert-green"></div>
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-desert-green"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : wikipediaState && wikipediaState.options.length > 0 ? (
|
) : wikipediaState && wikipediaState.options.length > 0 ? (
|
||||||
<div className="mt-8 bg-white rounded-lg border border-gray-200 p-6">
|
<div className="mt-8 bg-surface-primary rounded-lg border border-border-subtle p-6">
|
||||||
<WikipediaSelector
|
<WikipediaSelector
|
||||||
options={wikipediaState.options}
|
options={wikipediaState.options}
|
||||||
currentSelection={wikipediaState.currentSelection}
|
currentSelection={wikipediaState.currentSelection}
|
||||||
|
|
@ -332,12 +332,12 @@ export default function ZimRemoteExplorer() {
|
||||||
|
|
||||||
{/* Tiered Category Collections */}
|
{/* Tiered Category Collections */}
|
||||||
<div className="flex items-center gap-3 mt-8 mb-4">
|
<div className="flex items-center gap-3 mt-8 mb-4">
|
||||||
<div className="w-10 h-10 rounded-full bg-white border border-gray-200 flex items-center justify-center shadow-sm">
|
<div className="w-10 h-10 rounded-full bg-surface-primary border border-border-subtle flex items-center justify-center shadow-sm">
|
||||||
<IconBooks className="w-6 h-6 text-gray-700" />
|
<IconBooks className="w-6 h-6 text-text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-semibold text-gray-900">Additional Content</h3>
|
<h3 className="text-xl font-semibold text-text-primary">Additional Content</h3>
|
||||||
<p className="text-sm text-gray-500">Curated collections for offline reference</p>
|
<p className="text-sm text-text-muted">Curated collections for offline reference</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{categories && categories.length > 0 ? (
|
{categories && categories.length > 0 ? (
|
||||||
|
|
@ -363,7 +363,7 @@ export default function ZimRemoteExplorer() {
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-500 mt-4">No curated content categories available.</p>
|
<p className="text-text-muted mt-4">No curated content categories available.</p>
|
||||||
)}
|
)}
|
||||||
<StyledSectionHeader title="Browse the Kiwix Library" className="mt-12 mb-4" />
|
<StyledSectionHeader title="Browse the Kiwix Library" className="mt-12 mb-4" />
|
||||||
<div className="flex justify-start mt-4">
|
<div className="flex justify-start mt-4">
|
||||||
|
|
@ -377,7 +377,7 @@ export default function ZimRemoteExplorer() {
|
||||||
debouncedSetQuery(e.target.value)
|
debouncedSetQuery(e.target.value)
|
||||||
}}
|
}}
|
||||||
className="w-1/3"
|
className="w-1/3"
|
||||||
leftIcon={<IconSearch className="w-5 h-5 text-gray-400" />}
|
leftIcon={<IconSearch className="w-5 h-5 text-text-muted" />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<StyledTable<RemoteZimFileEntry & { actions?: any }>
|
<StyledTable<RemoteZimFileEntry & { actions?: any }>
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ const NotificationsProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
{notifications.map((notification) => (
|
{notifications.map((notification) => (
|
||||||
<div
|
<div
|
||||||
key={notification.id}
|
key={notification.id}
|
||||||
className={`mb-4 p-4 rounded shadow-md border border-slate-300 bg-white max-w-96`}
|
className={`mb-4 p-4 rounded shadow-md border border-border-default bg-surface-primary max-w-96`}
|
||||||
onClick={() => removeNotification(notification.id)}
|
onClick={() => removeNotification(notification.id)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-start gap-3">
|
<div className="flex flex-row items-start gap-3">
|
||||||
|
|
|
||||||
27
admin/inertia/providers/ThemeProvider.tsx
Normal file
27
admin/inertia/providers/ThemeProvider.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { createContext, useContext } from 'react'
|
||||||
|
import { useTheme, Theme } from '~/hooks/useTheme'
|
||||||
|
|
||||||
|
interface ThemeContextType {
|
||||||
|
theme: Theme
|
||||||
|
setTheme: (theme: Theme) => void
|
||||||
|
toggleTheme: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextType>({
|
||||||
|
theme: 'light',
|
||||||
|
setTheme: () => {},
|
||||||
|
toggleTheme: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const themeState = useTheme()
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={themeState}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useThemeContext() {
|
||||||
|
return useContext(ThemeContext)
|
||||||
|
}
|
||||||
81
admin/package-lock.json
generated
81
admin/package-lock.json
generated
|
|
@ -44,7 +44,7 @@
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
"dockerode": "^4.0.7",
|
"dockerode": "^4.0.7",
|
||||||
"edge.js": "^6.2.1",
|
"edge.js": "^6.2.1",
|
||||||
"fast-xml-parser": "^5.3.8",
|
"fast-xml-parser": "^5.5.6",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"luxon": "^3.6.1",
|
"luxon": "^3.6.1",
|
||||||
"maplibre-gl": "^4.7.1",
|
"maplibre-gl": "^4.7.1",
|
||||||
|
|
@ -64,9 +64,9 @@
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"stopword": "^3.1.5",
|
"stopword": "^3.1.5",
|
||||||
"systeminformation": "^5.30.8",
|
"systeminformation": "^5.31.0",
|
||||||
"tailwindcss": "^4.1.10",
|
"tailwindcss": "^4.1.10",
|
||||||
"tar": "^7.5.10",
|
"tar": "^7.5.11",
|
||||||
"tesseract.js": "^7.0.0",
|
"tesseract.js": "^7.0.0",
|
||||||
"url-join": "^5.0.0",
|
"url-join": "^5.0.0",
|
||||||
"yaml": "^2.8.0"
|
"yaml": "^2.8.0"
|
||||||
|
|
@ -4379,6 +4379,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -4395,6 +4396,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -4411,6 +4413,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -4427,6 +4430,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -4443,6 +4447,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -4459,6 +4464,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -4475,6 +4481,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -4491,6 +4498,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -4507,6 +4515,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -4523,6 +4532,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -6842,9 +6852,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cheerio/node_modules/undici": {
|
"node_modules/cheerio/node_modules/undici": {
|
||||||
"version": "7.20.0",
|
"version": "7.24.3",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.3.tgz",
|
||||||
"integrity": "sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ==",
|
"integrity": "sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.18.1"
|
"node": ">=20.18.1"
|
||||||
|
|
@ -8609,10 +8619,10 @@
|
||||||
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-xml-parser": {
|
"node_modules/fast-xml-builder": {
|
||||||
"version": "5.3.8",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
|
||||||
"integrity": "sha512-53jIF4N6u/pxvaL1eb/hEZts/cFLWZ92eCfLrNyCI0k38lettCG/Bs40W9pPwoPXyHQlKu2OUbQtiEIZK/J6Vw==",
|
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -8621,6 +8631,23 @@
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"path-expression-matcher": "^1.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fast-xml-parser": {
|
||||||
|
"version": "5.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.6.tgz",
|
||||||
|
"integrity": "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-xml-builder": "^1.1.4",
|
||||||
|
"path-expression-matcher": "^1.1.3",
|
||||||
"strnum": "^2.1.2"
|
"strnum": "^2.1.2"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
@ -12928,6 +12955,21 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-expression-matcher": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-key": {
|
"node_modules/path-key": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
|
|
@ -15201,10 +15243,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/systeminformation": {
|
"node_modules/systeminformation": {
|
||||||
"version": "5.30.8",
|
"version": "5.31.0",
|
||||||
"resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.30.8.tgz",
|
"resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.0.tgz",
|
||||||
"integrity": "sha512-imB8LwJCc2DkufKlSRHfzbjhheGzpg1P31A4c55IKTq/ll6Agn1rhBOY+WmS/hyg5inGFp7AyZIK0gvq5rFO2Q==",
|
"integrity": "sha512-z5pjzvC8UnQJ/iu34z+mo3lAeMzTGdArjPQoG5uPyV5XY4BY+M6ZcRTl4XnZqudz6sP713LhWMKv6e0kGFGCgQ==",
|
||||||
"license": "MIT",
|
|
||||||
"os": [
|
"os": [
|
||||||
"darwin",
|
"darwin",
|
||||||
"linux",
|
"linux",
|
||||||
|
|
@ -15265,9 +15306,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tar": {
|
"node_modules/tar": {
|
||||||
"version": "7.5.10",
|
"version": "7.5.11",
|
||||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.10.tgz",
|
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz",
|
||||||
"integrity": "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==",
|
"integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==",
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@isaacs/fs-minipass": "^4.0.0",
|
"@isaacs/fs-minipass": "^4.0.0",
|
||||||
|
|
@ -15802,9 +15843,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "6.23.0",
|
"version": "6.24.1",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",
|
||||||
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
|
"integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.17"
|
"node": ">=18.17"
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
"dockerode": "^4.0.7",
|
"dockerode": "^4.0.7",
|
||||||
"edge.js": "^6.2.1",
|
"edge.js": "^6.2.1",
|
||||||
"fast-xml-parser": "^5.3.8",
|
"fast-xml-parser": "^5.5.6",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"luxon": "^3.6.1",
|
"luxon": "^3.6.1",
|
||||||
"maplibre-gl": "^4.7.1",
|
"maplibre-gl": "^4.7.1",
|
||||||
|
|
@ -116,9 +116,9 @@
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"stopword": "^3.1.5",
|
"stopword": "^3.1.5",
|
||||||
"systeminformation": "^5.30.8",
|
"systeminformation": "^5.31.0",
|
||||||
"tailwindcss": "^4.1.10",
|
"tailwindcss": "^4.1.10",
|
||||||
"tar": "^7.5.10",
|
"tar": "^7.5.11",
|
||||||
"tesseract.js": "^7.0.0",
|
"tesseract.js": "^7.0.0",
|
||||||
"url-join": "^5.0.0",
|
"url-join": "^5.0.0",
|
||||||
"yaml": "^2.8.0"
|
"yaml": "^2.8.0"
|
||||||
|
|
|
||||||
BIN
admin/public/rogue-support-banner.png
Normal file
BIN
admin/public/rogue-support-banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 251 KiB |
|
|
@ -11,6 +11,17 @@
|
||||||
|
|
||||||
<title inertia>Project N.O.M.A.D</title>
|
<title inertia>Project N.O.M.A.D</title>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
var theme = localStorage.getItem('nomad:theme');
|
||||||
|
if (theme === 'dark') {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
@stack('dumper')
|
@stack('dumper')
|
||||||
@viteReactRefresh()
|
@viteReactRefresh()
|
||||||
@inertiaHead()
|
@inertiaHead()
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ router
|
||||||
router.get('/zim', [SettingsController, 'zim'])
|
router.get('/zim', [SettingsController, 'zim'])
|
||||||
router.get('/zim/remote-explorer', [SettingsController, 'zimRemote'])
|
router.get('/zim/remote-explorer', [SettingsController, 'zimRemote'])
|
||||||
router.get('/benchmark', [SettingsController, 'benchmark'])
|
router.get('/benchmark', [SettingsController, 'benchmark'])
|
||||||
|
router.get('/support', [SettingsController, 'support'])
|
||||||
})
|
})
|
||||||
.prefix('/settings')
|
.prefix('/settings')
|
||||||
|
|
||||||
|
|
@ -91,6 +92,7 @@ router
|
||||||
.group(() => {
|
.group(() => {
|
||||||
router.get('/jobs', [DownloadsController, 'index'])
|
router.get('/jobs', [DownloadsController, 'index'])
|
||||||
router.get('/jobs/:filetype', [DownloadsController, 'filetype'])
|
router.get('/jobs/:filetype', [DownloadsController, 'filetype'])
|
||||||
|
router.delete('/jobs/:jobId', [DownloadsController, 'removeJob'])
|
||||||
})
|
})
|
||||||
.prefix('/api/downloads')
|
.prefix('/api/downloads')
|
||||||
|
|
||||||
|
|
@ -135,6 +137,7 @@ router
|
||||||
|
|
||||||
router
|
router
|
||||||
.group(() => {
|
.group(() => {
|
||||||
|
router.get('/debug-info', [SystemController, 'getDebugInfo'])
|
||||||
router.get('/info', [SystemController, 'getSystemInfo'])
|
router.get('/info', [SystemController, 'getSystemInfo'])
|
||||||
router.get('/internet-status', [SystemController, 'getInternetStatus'])
|
router.get('/internet-status', [SystemController, 'getInternetStatus'])
|
||||||
router.get('/services', [SystemController, 'getServices'])
|
router.get('/services', [SystemController, 'getServices'])
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ export type DownloadJobWithProgress = {
|
||||||
progress: number
|
progress: number
|
||||||
filepath: string
|
filepath: string
|
||||||
filetype: string
|
filetype: string
|
||||||
|
status?: 'active' | 'failed'
|
||||||
|
failedReason?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wikipedia selector types
|
// Wikipedia selector types
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@ export const KV_STORE_SCHEMA = {
|
||||||
'system.latestVersion': 'string',
|
'system.latestVersion': 'string',
|
||||||
'system.earlyAccess': 'boolean',
|
'system.earlyAccess': 'boolean',
|
||||||
'ui.hasVisitedEasySetup': 'boolean',
|
'ui.hasVisitedEasySetup': 'boolean',
|
||||||
|
'ui.theme': 'string',
|
||||||
'ai.assistantCustomName': 'string',
|
'ai.assistantCustomName': 'string',
|
||||||
|
'gpu.type': 'string',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
type KVTagToType<T extends string> = T extends 'boolean' ? boolean : string
|
type KVTagToType<T extends string> = T extends 'boolean' ? boolean : string
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user