[
  {
    "path": ".dockerignore",
    "content": ".env\n.env.*\n.git\nnode_modules\n*.log\nadmin/storage\nadmin/node_modules\nadmin/build"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: Bug Report\ndescription: Report a bug or issue with Project N.O.M.A.D.\ntitle: \"[Bug]: \"\nlabels: [\"bug\", \"needs-triage\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for taking the time to report a bug! Please fill out the information below to help us diagnose and fix the issue.\n        \n        **Before submitting:**\n        - Search existing issues to avoid duplicates\n        - Ensure you're running the latest version of N.O.M.A.D.\n        - Redact any personal or sensitive information from logs/configs\n        - Please don't submit issues related to running N.O.M.A.D. on Unraid or another NAS - we don't have plans to support these kinds of platforms at this time\n\n  - type: dropdown\n    id: issue-category\n    attributes:\n      label: Issue Category\n      description: What area is this issue related to?\n      options:\n        - Installation/Setup\n        - AI Assistant (Ollama)\n        - Knowledge Base/RAG (Document Upload)\n        - Docker/Container Issues\n        - GPU Configuration\n        - Content Downloads (ZIM, Maps, Collections)\n        - Service Management (Start/Stop/Update)\n        - System Performance/Resources\n        - UI/Frontend Issue\n        - Other\n    validations:\n      required: true\n\n  - type: textarea\n    id: description\n    attributes:\n      label: Bug Description\n      description: Provide a clear and concise description of what the bug is\n      placeholder: What happened? What did you expect to happen?\n    validations:\n      required: true\n\n  - type: textarea\n    id: reproduction\n    attributes:\n      label: Steps to Reproduce\n      description: How can we reproduce this issue?\n      placeholder: |\n        1. Go to '...'\n        2. Click on '...'\n        3. See error\n    validations:\n      required: true\n\n  - type: textarea\n    id: expected-behavior\n    attributes:\n      label: Expected Behavior\n      description: What did you expect to happen?\n      placeholder: Describe the expected outcome\n    validations:\n      required: true\n\n  - type: textarea\n    id: actual-behavior\n    attributes:\n      label: Actual Behavior\n      description: What actually happened?\n      placeholder: Describe what actually occurred, including any error messages\n    validations:\n      required: true\n\n  - type: input\n    id: nomad-version\n    attributes:\n      label: N.O.M.A.D. Version\n      description: What version of N.O.M.A.D. are you running? (Check Settings > Update or run `docker ps` and check nomad_admin image tag)\n      placeholder: \"e.g., 1.29.0\"\n    validations:\n      required: true\n\n  - type: dropdown\n    id: os\n    attributes:\n      label: Operating System\n      description: What OS are you running N.O.M.A.D. on?\n      options:\n        - Ubuntu 24.04\n        - Ubuntu 22.04\n        - Ubuntu 20.04\n        - Debian 13 (Trixie)\n        - Debian 12 (Bookworm)\n        - Debian 11 (Bullseye)\n        - Other Debian-based\n        - Other (not yet officially supported)\n    validations:\n      required: true\n\n  - type: input\n    id: docker-version\n    attributes:\n      label: Docker Version\n      description: What version of Docker are you running? (`docker --version`)\n      placeholder: \"e.g., Docker version 24.0.7\"\n\n  - type: dropdown\n    id: gpu-present\n    attributes:\n      label: Do you have a dedicated GPU?\n      options:\n        - \"Yes\"\n        - \"No\"\n        - \"Not sure\"\n    validations:\n      required: true\n\n  - type: input\n    id: gpu-model\n    attributes:\n      label: GPU Model (if applicable)\n      description: What GPU model do you have? (Check Settings > System or run `nvidia-smi` if NVIDIA GPU)\n      placeholder: \"e.g., NVIDIA GeForce RTX 3060\"\n\n  - type: textarea\n    id: system-specs\n    attributes:\n      label: System Specifications\n      description: Provide relevant system specs (CPU, RAM, available disk space)\n      placeholder: |\n        CPU: \n        RAM: \n        Available Disk Space: \n        GPU (if any): \n\n  - type: textarea\n    id: service-status\n    attributes:\n      label: Service Status (if relevant)\n      description: If this is a service-related issue, what's the status of relevant services? (Check Settings > Apps or run `docker ps`)\n      placeholder: |\n        Paste output from `docker ps` or describe service states from the UI\n\n  - type: textarea\n    id: logs\n    attributes:\n      label: Relevant Logs\n      description: |\n        Include any relevant logs or error messages. **Please redact any personal/sensitive information.**\n        \n        Useful commands for collecting logs:\n        - N.O.M.A.D. management app: `docker logs nomad_admin`\n        - Ollama: `docker logs nomad_ollama`\n        - Qdrant: `docker logs nomad_qdrant`\n        - Specific service: `docker logs nomad_<service-name>`\n      placeholder: Paste relevant log output here\n      render: shell\n\n  - type: textarea\n    id: browser-console\n    attributes:\n      label: Browser Console Errors (if UI issue)\n      description: If this is a UI issue, include any errors from your browser's developer console (F12)\n      placeholder: Paste browser console errors here\n      render: javascript\n\n  - type: textarea\n    id: screenshots\n    attributes:\n      label: Screenshots\n      description: If applicable, add screenshots to help explain your problem (drag and drop images here)\n\n  - type: textarea\n    id: additional-context\n    attributes:\n      label: Additional Context\n      description: Add any other context about the problem here (network setup, custom configurations, recent changes, etc.)\n\n  - type: checkboxes\n    id: terms\n    attributes:\n      label: Pre-submission Checklist\n      description: Please confirm the following before submitting\n      options:\n        - label: I have searched for existing issues that might be related to this bug\n          required: true\n        - label: I am running the latest version of Project N.O.M.A.D. (or have noted my version above)\n          required: true\n        - label: I have redacted any personal or sensitive information from logs and screenshots\n          required: true\n        - label: This issue is NOT related to running N.O.M.A.D. on an unsupported/non-Debian-based OS\n          required: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: 💬 Discord Community\n    url: https://discord.com/invite/crosstalksolutions\n    about: Join our Discord community for general questions, support, and discussions\n  - name: 📖 Documentation\n    url: https://projectnomad.us\n    about: Check the official documentation and guides\n  - name: 🏆 Community Leaderboard\n    url: https://benchmark.projectnomad.us\n    about: View the N.O.M.A.D. benchmark leaderboard\n  - name: 🤝 Contributing Guide\n    url: https://github.com/Crosstalk-Solutions/project-nomad/blob/main/CONTRIBUTING.md\n    about: Learn how to contribute to Project N.O.M.A.D.\n  - name: 📅 Roadmap\n    url: https://roadmap.projectnomad.us\n    about: See our public roadmap, vote on features, and suggest new ones"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yml",
    "content": "name: Feature Request\ndescription: Suggest a new feature or enhancement for Project N.O.M.A.D.\ntitle: \"[Feature]: \"\nlabels: [\"enhancement\", \"needs-discussion\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        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.\n\n        \n        **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.\n        \n        **Before submitting:**\n        - Search existing feature requests and our [roadmap](https://roadmap.projectnomad.us) to avoid duplicates\n        - Consider if this aligns with N.O.M.A.D.'s mission: offline-first knowledge and education\n        - 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\n        - 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\n        - If you're able to contribute code, testing, or documentation, that significantly increases the chances of your feature being implemented\n\n  - type: dropdown\n    id: feature-category\n    attributes:\n      label: Feature Category\n      description: What area does this feature relate to?\n      options:\n        - New Service/Tool Integration\n        - AI Assistant Enhancement\n        - Knowledge Base/RAG Improvement\n        - Content Management (ZIM, Maps, Collections)\n        - UI/UX Improvement\n        - System Management\n        - Performance Optimization\n        - Documentation\n        - Security\n        - Other\n    validations:\n      required: true\n\n  - type: textarea\n    id: problem\n    attributes:\n      label: Problem Statement\n      description: What problem does this feature solve? Is your feature request related to a pain point?\n      placeholder: I find it frustrating when... / It would be helpful if... / Users struggle with...\n    validations:\n      required: true\n\n  - type: textarea\n    id: solution\n    attributes:\n      label: Proposed Solution\n      description: Describe the feature or enhancement you'd like to see\n      placeholder: Add a feature that... / Change the behavior to... / Integrate with...\n    validations:\n      required: true\n\n  - type: textarea\n    id: alternatives\n    attributes:\n      label: Alternative Solutions\n      description: Have you considered any alternative solutions or workarounds?\n      placeholder: I've tried... / Another approach could be... / A workaround is...\n\n  - type: textarea\n    id: use-case\n    attributes:\n      label: Use Case\n      description: Describe a specific scenario where this feature would be valuable\n      placeholder: |\n        As a [type of user], when I [do something], I want to [accomplish something] so that [benefit].\n        \n        Example: Because I have a dedicated GPU, I want to be able to see in the UI if GPU support is enabled so that I can optimize performance and troubleshoot issues more easily.\n\n  - type: dropdown\n    id: user-type\n    attributes:\n      label: Who would benefit from this feature?\n      description: What type of users would find this most valuable?\n      multiple: true\n      options:\n        - Individual/Home Users\n        - Families\n        - Teachers/Educators\n        - Students\n        - Survivalists/Preppers\n        - Developers/Contributors\n        - Organizations\n        - All Users\n    validations:\n      required: true\n\n  - type: dropdown\n    id: priority\n    attributes:\n      label: How important is this feature to you?\n      options:\n        - Critical - Blocking my use of N.O.M.A.D.\n        - High - Would significantly improve my experience\n        - Medium - Would be nice to have\n        - Low - Minor convenience\n    validations:\n      required: true\n\n  - type: textarea\n    id: implementation-ideas\n    attributes:\n      label: Implementation Ideas (Optional)\n      description: If you have technical suggestions for how this could be implemented, share them here\n      placeholder: This could potentially use... / It might integrate with... / A possible approach is...\n\n  - type: textarea\n    id: examples\n    attributes:\n      label: Examples or References\n      description: Are there similar features in other applications? Include links, screenshots, or descriptions\n      placeholder: Similar to how [app name] does... / See this example at [URL]\n\n  - type: dropdown\n    id: willing-to-contribute\n    attributes:\n      label: Would you be willing to help implement this?\n      description: Contributing increases the likelihood of implementation\n      options:\n        - \"Yes - I can write the code\"\n        - \"Yes - I can help test\"\n        - \"Yes - I can help with documentation\"\n        - \"Maybe - with guidance\"\n        - \"No - I don't have the skills/time\"\n    validations:\n      required: true\n\n  - type: textarea\n    id: additional-context\n    attributes:\n      label: Additional Context\n      description: Add any other context, mockups, diagrams, or information about the feature request\n\n  - type: checkboxes\n    id: checklist\n    attributes:\n      label: Pre-submission Checklist\n      description: Please confirm the following before submitting\n      options:\n        - label: I have searched for existing feature requests that might be similar\n          required: true\n        - label: This feature aligns with N.O.M.A.D.'s mission of offline-first knowledge and education\n          required: true\n        - label: I understand that feature requests are not guaranteed to be implemented\n          required: true\n"
  },
  {
    "path": ".github/dependabot.yaml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: \"npm\"\n    directory: \"/admin\"\n    schedule:\n      interval: \"weekly\"\n    target-branch: \"rc\""
  },
  {
    "path": ".github/scripts/finalize-release-notes.sh",
    "content": "#!/usr/bin/env bash\n#\n# finalize-release-notes.sh\n#\n# Stamps the \"## Unreleased\" section in a release-notes file with a version\n# and date, and extracts the section content for use in GitHub releases / email.\n# Also includes all commits since the last release for complete transparency.\n#\n# Usage:  finalize-release-notes.sh <version> <file-path>\n#\n# Exit codes:\n#   0 - Success: section stamped and extracted\n#   1 - No \"## Unreleased\" section found (skip gracefully)\n#   2 - Unreleased section exists but is empty (skip gracefully)\n\nset -euo pipefail\n\nVERSION=\"${1:?Usage: finalize-release-notes.sh <version> <file-path>}\"\nFILE=\"${2:?Usage: finalize-release-notes.sh <version> <file-path>}\"\n\nif [[ ! -f \"$FILE\" ]]; then\n  echo \"Error: File not found: $FILE\" >&2\n  exit 1\nfi\n\n# Find the line number of the ## Unreleased header (case-insensitive)\nHEADER_LINE=$(grep -inm1 '^## unreleased' \"$FILE\" | cut -d: -f1)\n\nif [[ -z \"$HEADER_LINE\" ]]; then\n  echo \"No '## Unreleased' section found. Skipping.\"\n  exit 1\nfi\n\nTOTAL_LINES=$(wc -l < \"$FILE\")\n\n# Find the next section header (## Version ...) or --- separator after the Unreleased header\nNEXT_SECTION_LINE=\"\"\nif [[ $HEADER_LINE -lt $TOTAL_LINES ]]; then\n  NEXT_SECTION_LINE=$(tail -n +\"$((HEADER_LINE + 1))\" \"$FILE\" \\\n    | grep -nm1 '^## \\|^---$' \\\n    | cut -d: -f1)\nfi\n\nif [[ -n \"$NEXT_SECTION_LINE\" ]]; then\n  # NEXT_SECTION_LINE is relative to HEADER_LINE+1, convert to absolute\n  END_LINE=$((HEADER_LINE + NEXT_SECTION_LINE - 1))\nelse\n  # Section runs to end of file\n  END_LINE=$TOTAL_LINES\nfi\n\n# Extract content between header and next section (exclusive of both boundaries)\nCONTENT_START=$((HEADER_LINE + 1))\nCONTENT_END=$END_LINE\n\n# Extract the section body (between header line and the next boundary)\nSECTION_BODY=$(sed -n \"${CONTENT_START},${CONTENT_END}p\" \"$FILE\" | sed '/^$/N;/^\\n$/d')\n\n# Check for actual content: strip blank lines and lines that are only markdown headers (###...)\nTRIMMED=$(echo \"$SECTION_BODY\" | sed '/^[[:space:]]*$/d')\nHAS_CONTENT=$(echo \"$SECTION_BODY\" | sed '/^[[:space:]]*$/d' | grep -v '^###' || true)\n\nif [[ -z \"$TRIMMED\" || -z \"$HAS_CONTENT\" ]]; then\n  echo \"Unreleased section is empty. Skipping.\"\n  exit 2\nfi\n\n# Format the date as \"Month Day, Year\"\nDATE_STAMP=$(date +'%B %-d, %Y')\nNEW_HEADER=\"## Version ${VERSION} - ${DATE_STAMP}\"\n\n# Build the replacement: swap the header line, keep everything else intact\n{\n  # Lines before the Unreleased header\n  if [[ $HEADER_LINE -gt 1 ]]; then\n    head -n \"$((HEADER_LINE - 1))\" \"$FILE\"\n  fi\n  # New versioned header\n  echo \"$NEW_HEADER\"\n  # Content between header and next section\n  sed -n \"${CONTENT_START},${CONTENT_END}p\" \"$FILE\"\n  # Rest of the file after the section\n  if [[ $END_LINE -lt $TOTAL_LINES ]]; then\n    tail -n +\"$((END_LINE + 1))\" \"$FILE\"\n  fi\n} > \"${FILE}.tmp\"\n\nmv \"${FILE}.tmp\" \"$FILE\"\n\n# Get commits since the last release\nLAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo \"\")\nCOMMIT_LIST=\"\"\n\nif [[ -n \"$LAST_TAG\" ]]; then\n  echo \"Fetching commits since ${LAST_TAG}...\"\n  # Get commits between last tag and HEAD, excluding merge commits and skip ci commits\n  COMMIT_LIST=$(git log \"${LAST_TAG}..HEAD\" \\\n    --no-merges \\\n    --pretty=format:\"- %s ([%h](https://github.com/${GITHUB_REPOSITORY}/commit/%H))\" \\\n    --grep=\"\\[skip ci\\]\" --invert-grep \\\n    || echo \"\")\nelse\n  echo \"No previous tag found, fetching all commits...\"\n  COMMIT_LIST=$(git log \\\n    --no-merges \\\n    --pretty=format:\"- %s ([%h](https://github.com/${GITHUB_REPOSITORY}/commit/%H))\" \\\n    --grep=\"\\[skip ci\\]\" --invert-grep \\\n    || echo \"\")\nfi\n\n# Write the extracted section content (for GitHub release body / future email)\n{\n  echo \"$NEW_HEADER\"\n  echo \"\"\n  if [[ -n \"$TRIMMED\" ]]; then\n    echo \"$TRIMMED\"\n    echo \"\"\n  fi\n  \n  # Add commit history if available\n  if [[ -n \"$COMMIT_LIST\" ]]; then\n    echo \"---\"\n    echo \"\"\n    echo \"### 📝 All Changes\"\n    echo \"\"\n    echo \"$COMMIT_LIST\"\n  fi\n} > \"${FILE}.section\"\n\necho \"Finalized release notes for v${VERSION}\"\necho \"  Updated: ${FILE}\"\necho \"  Extracted: ${FILE}.section\"\nexit 0\n"
  },
  {
    "path": ".github/workflows/build-disk-collector.yml",
    "content": "name: Build Disk Collector Image\n\non:\n  workflow_dispatch:\n    inputs:\n      version:\n        description: 'Semantic version to label the Docker image under (no \"v\" prefix, e.g. \"1.2.3\")'\n        required: true\n        type: string\n      tag_latest:\n        description: 'Also tag this image as :latest?'\n        required: false\n        type: boolean\n        default: false\n\njobs:\n  check_authorization:\n    name: Check authorization to publish new Docker image\n    runs-on: ubuntu-latest\n    outputs:\n      isAuthorized: ${{ steps.check-auth.outputs.is_authorized }}\n    steps:\n      - name: check-auth\n        id: check-auth\n        run: echo \"is_authorized=${{ contains(secrets.DEPLOYMENT_AUTHORIZED_USERS, github.triggering_actor) }}\" >> $GITHUB_OUTPUT\n  build:\n    name: Build disk-collector image\n    needs: check_authorization\n    if: needs.check_authorization.outputs.isAuthorized == 'true'\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      packages: write\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n      - name: Log in to GitHub Container Registry\n        uses: docker/login-action@v2\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n      - name: Build and push\n        uses: docker/build-push-action@v5\n        with:\n          context: install/sidecar-disk-collector\n          push: true\n          tags: |\n            ghcr.io/crosstalk-solutions/project-nomad-disk-collector:${{ inputs.version }}\n            ghcr.io/crosstalk-solutions/project-nomad-disk-collector:v${{ inputs.version }}\n            ${{ inputs.tag_latest && 'ghcr.io/crosstalk-solutions/project-nomad-disk-collector:latest' || '' }}\n"
  },
  {
    "path": ".github/workflows/build-primary-image.yml",
    "content": "name: Build Primary Docker Image\n\non:\n  workflow_dispatch:\n    inputs:\n      version:\n        description: 'Semantic version to label the Docker image under (no \"v\" prefix, e.g. \"1.2.3\")'\n        required: true\n        type: string\n      tag_latest:\n        description: 'Also tag this image as :latest? (Keep false for RC and beta releases)'\n        required: false\n        type: boolean\n        default: false\n\njobs:\n  check_authorization:\n    name: Check authorization to publish new Docker image\n    runs-on: ubuntu-latest\n    outputs:\n      isAuthorized: ${{ steps.check-auth.outputs.is_authorized }}\n    steps:\n      - name: check-auth\n        id: check-auth\n        run: echo \"is_authorized=${{ contains(secrets.DEPLOYMENT_AUTHORIZED_USERS, github.triggering_actor) }}\" >> $GITHUB_OUTPUT      \n  build:\n    name: Build Docker image\n    needs: check_authorization\n    if: needs.check_authorization.outputs.isAuthorized == 'true'\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      packages: write\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n      - name: Log in to GitHub Container Registry\n        uses: docker/login-action@v2\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n      - name: Build and push\n        uses: docker/build-push-action@v5\n        with:\n          push: true\n          tags: |\n            ghcr.io/crosstalk-solutions/project-nomad:${{ inputs.version }}\n            ghcr.io/crosstalk-solutions/project-nomad:v${{ inputs.version }}\n            ${{ inputs.tag_latest && 'ghcr.io/crosstalk-solutions/project-nomad:latest' || '' }}\n          build-args: |\n            VERSION=${{ inputs.version }}\n            BUILD_DATE=${{ github.event.workflow_run.created_at }}\n            VCS_REF=${{ github.sha }}\n"
  },
  {
    "path": ".github/workflows/build-sidecar-updater.yml",
    "content": "name: Build Sidecar Updater Image\n\non:\n  workflow_dispatch:\n    inputs:\n      version:\n        description: 'Semantic version to label the Docker image under (no \"v\" prefix, e.g. \"1.2.3\")'\n        required: true\n        type: string\n      tag_latest:\n        description: 'Also tag this image as :latest?'\n        required: false\n        type: boolean\n        default: false\n\njobs:\n  check_authorization:\n    name: Check authorization to publish new Docker image\n    runs-on: ubuntu-latest\n    outputs:\n      isAuthorized: ${{ steps.check-auth.outputs.is_authorized }}\n    steps:\n      - name: check-auth\n        id: check-auth\n        run: echo \"is_authorized=${{ contains(secrets.DEPLOYMENT_AUTHORIZED_USERS, github.triggering_actor) }}\" >> $GITHUB_OUTPUT\n  build:\n    name: Build sidecar-updater image\n    needs: check_authorization\n    if: needs.check_authorization.outputs.isAuthorized == 'true'\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      packages: write\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n      - name: Log in to GitHub Container Registry\n        uses: docker/login-action@v2\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n      - name: Build and push\n        uses: docker/build-push-action@v5\n        with:\n          context: install/sidecar-updater\n          push: true\n          tags: |\n            ghcr.io/crosstalk-solutions/project-nomad-sidecar-updater:${{ inputs.version }}\n            ghcr.io/crosstalk-solutions/project-nomad-sidecar-updater:v${{ inputs.version }}\n            ${{ inputs.tag_latest && 'ghcr.io/crosstalk-solutions/project-nomad-sidecar-updater:latest' || '' }}\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release SemVer\n\non: workflow_dispatch\n\njobs:\n  check_authorization:\n    name: Check authorization to release new version\n    runs-on: ubuntu-latest\n    outputs:\n      isAuthorized: ${{ steps.check-auth.outputs.is_authorized }}\n    steps:\n      - name: check-auth\n        id: check-auth\n        run: echo \"is_authorized=${{ contains(secrets.DEPLOYMENT_AUTHORIZED_USERS, github.triggering_actor) }}\" >> $GITHUB_OUTPUT\n  release:\n    name: Release\n    needs: check_authorization\n    if: needs.check_authorization.outputs.isAuthorized == 'true'\n    runs-on: ubuntu-latest\n    outputs:\n      didRelease: ${{ steps.semver.outputs.new_release_published }}\n      newVersion: ${{ steps.semver.outputs.new_release_version }}\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n          persist-credentials: false\n      - name: semantic-release\n        uses: cycjimmy/semantic-release-action@v3\n        id: semver\n        env:\n          GITHUB_TOKEN: ${{ secrets.COSMISTACKBOT_ACCESS_TOKEN }}\n          GIT_AUTHOR_NAME: cosmistack-bot\n          GIT_AUTHOR_EMAIL: dev@cosmistack.com\n          GIT_COMMITTER_NAME: cosmistack-bot\n          GIT_COMMITTER_EMAIL: dev@cosmistack.com\n\n      - name: Finalize release notes\n        # Skip for pre-releases (versions containing a hyphen, e.g. 1.27.0-rc.1)\n        if: |\n          steps.semver.outputs.new_release_published == 'true' &&\n          !contains(steps.semver.outputs.new_release_version, '-')\n        id: finalize-notes\n        env:\n          GITHUB_REPOSITORY: ${{ github.repository }}\n        run: |\n          git pull origin main\n          chmod +x .github/scripts/finalize-release-notes.sh\n          EXIT_CODE=0\n          .github/scripts/finalize-release-notes.sh \\\n            \"${{ steps.semver.outputs.new_release_version }}\" \\\n            admin/docs/release-notes.md || EXIT_CODE=$?\n          if [[ \"$EXIT_CODE\" -eq 0 ]]; then\n            echo \"has_notes=true\" >> $GITHUB_OUTPUT\n          else\n            echo \"has_notes=false\" >> $GITHUB_OUTPUT\n          fi\n\n      - name: Commit finalized release notes\n        if: |\n          steps.semver.outputs.new_release_published == 'true' &&\n          steps.finalize-notes.outputs.has_notes == 'true' &&\n          !contains(steps.semver.outputs.new_release_version, '-')\n        run: |\n          git config user.name \"cosmistack-bot\"\n          git config user.email \"dev@cosmistack.com\"\n          git remote set-url origin https://x-access-token:${{ secrets.COSMISTACKBOT_ACCESS_TOKEN }}@github.com/${{ github.repository }}.git\n          git add admin/docs/release-notes.md\n          git commit -m \"docs(release): finalize v${{ steps.semver.outputs.new_release_version }} release notes [skip ci]\"\n          git push origin main\n\n      - name: Update GitHub release body\n        if: |\n          steps.semver.outputs.new_release_published == 'true' &&\n          steps.finalize-notes.outputs.has_notes == 'true' &&\n          !contains(steps.semver.outputs.new_release_version, '-')\n        env:\n          GH_TOKEN: ${{ secrets.COSMISTACKBOT_ACCESS_TOKEN }}\n        run: |\n          gh release edit \"v${{ steps.semver.outputs.new_release_version }}\" \\\n            --notes-file admin/docs/release-notes.md.section\n\n      # Future: Send release notes email\n      # - name: Send release notes email\n      #   if: steps.semver.outputs.new_release_published == 'true' && steps.finalize-notes.outputs.has_notes == 'true'\n      #   run: |\n      #     curl -X POST \"https://api.projectnomad.us/api/v1/newsletter/release\" \\\n      #       -H \"Authorization: Bearer ${{ secrets.NOMAD_API_KEY }}\" \\\n      #       -H \"Content-Type: application/json\" \\\n      #       -d \"{\\\"version\\\": \\\"${{ steps.semver.outputs.new_release_version }}\\\", \\\"body\\\": $(cat admin/docs/release-notes.md.section | jq -Rs .)}\""
  },
  {
    "path": ".github/workflows/validate-collection-urls.yml",
    "content": "name: Validate Collection URLs\n\non:\n  push:\n    paths:\n      - 'collections/**.json'\n  pull_request:\n    paths:\n      - 'collections/**.json'\n\njobs:\n  validate-urls:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Extract and validate URLs\n        run: |\n          FAILED=0\n          CHECKED=0\n          FAILED_URLS=\"\"\n\n          # Recursively extract all non-null string URLs from every JSON file in collections/\n          URLS=$(jq -r '.. | .url? | select(type == \"string\")' collections/*.json | sort -u)\n\n          while IFS= read -r url; do\n            [ -z \"$url\" ] && continue\n            CHECKED=$((CHECKED + 1))\n            printf \"Checking: %s ... \" \"$url\"\n\n            # Use Range: bytes=0-0 to avoid downloading the full file.\n            # --max-filesize 1 aborts early if the server ignores the Range header\n            # and returns 200 with the full body. The HTTP status is still captured.\n            HTTP_CODE=$(curl -s -o /dev/null -w \"%{http_code}\" \\\n              --range 0-0 \\\n              --max-filesize 1 \\\n              --max-time 30 \\\n              --location \\\n              \"$url\")\n\n            if [ \"$HTTP_CODE\" = \"200\" ] || [ \"$HTTP_CODE\" = \"206\" ]; then\n              echo \"OK ($HTTP_CODE)\"\n            else\n              echo \"FAILED ($HTTP_CODE)\"\n              FAILED=$((FAILED + 1))\n              FAILED_URLS=\"$FAILED_URLS\\n  - $url (HTTP $HTTP_CODE)\"\n            fi\n          done <<< \"$URLS\"\n\n          echo \"\"\n          echo \"Checked $CHECKED URLs, $FAILED failed.\"\n\n          if [ \"$FAILED\" -gt 0 ]; then\n            echo \"\"\n            echo \"Broken URLs:\"\n            printf \"%b\\n\" \"$FAILED_URLS\"\n            exit 1\n          fi\n"
  },
  {
    "path": ".gitignore",
    "content": "# Logs\nlogs\n*.log\n\n# Diagnostic reports (https://nodejs.org/api/report.html)\nreport.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json\n\n# Compiled binary addons (https://nodejs.org/api/addons.html)\nbuild/Release\n\n# Dependency directories\nnode_modules/\n\n# Optional npm cache directory\n.npm\n\n# dotenv environment variables file\n.env\n\n# Build / Dist\ndist\nbuild\ntmp\n\n# macOS Metafiles\n.DS_Store\n\n# Fonts\n.ttf\n\n# Runtime-generated Files\nserver/public\nserver/temp\n\n# IDE Files\n.vscode\n.idea\n\n# Frontend assets compiled code\nadmin/public/assets\n\n# Admin specific development files\nadmin/storage\n"
  },
  {
    "path": ".releaserc.json",
    "content": "{\n  \"branches\": [\n    \"main\",\n    { \"name\": \"rc\", \"prerelease\": \"rc\" }\n  ],\n  \"plugins\": [\n    \"@semantic-release/commit-analyzer\",\n    \"@semantic-release/release-notes-generator\",\n    [\"@semantic-release/npm\", {\n      \"npmPublish\": false\n    }],\n    [\"@semantic-release/git\", {\n      \"assets\": [\"package.json\"],\n      \"message\": \"chore(release): ${nextRelease.version} [skip ci]\"\n    }],\n    \"@semantic-release/github\"\n  ]\n}"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participation in our\ncommunity a harassment-free experience for everyone, regardless of age, body\nsize, visible or invisible disability, ethnicity, sex characteristics, gender\nidentity and expression, level of experience, education, socio-economic status,\nnationality, personal appearance, race, religion, or sexual identity\nand orientation.\n\nWe pledge to act and interact in ways that contribute to an open, welcoming,\ndiverse, inclusive, and healthy community.\n\n## Our Standards\n\nExamples of behavior that contributes to a positive environment for our\ncommunity include:\n\n* Demonstrating empathy and kindness toward other people\n* Being respectful of differing opinions, viewpoints, and experiences\n* Giving and gracefully accepting constructive feedback\n* Accepting responsibility and apologizing to those affected by our mistakes,\n  and learning from the experience\n* Focusing on what is best not just for us as individuals, but for the\n  overall community\n\nExamples of unacceptable behavior include:\n\n* The use of sexualized language or imagery, and sexual attention or\n  advances of any kind\n* Trolling, insulting or derogatory comments, and personal or political attacks\n* Public or private harassment\n* Publishing others' private information, such as a physical or email\n  address, without their explicit permission\n* Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n## Enforcement Responsibilities\n\nCommunity leaders are responsible for clarifying and enforcing our standards of\nacceptable behavior and will take appropriate and fair corrective action in\nresponse to any behavior that they deem inappropriate, threatening, offensive,\nor harmful.\n\nCommunity leaders have the right and responsibility to remove, edit, or reject\ncomments, commits, code, wiki edits, issues, and other contributions that are\nnot aligned to this Code of Conduct, and will communicate reasons for moderation\ndecisions when appropriate.\n\n## Scope\n\nThis Code of Conduct applies within all community spaces, and also applies when\nan individual is officially representing the community in public spaces.\nExamples of representing our community include using an official e-mail address,\nposting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported to the community leaders responsible for enforcement at\n.\nAll complaints will be reviewed and investigated promptly and fairly.\n\nAll community leaders are obligated to respect the privacy and security of the\nreporter of any incident.\n\n## Enforcement Guidelines\n\nCommunity leaders will follow these Community Impact Guidelines in determining\nthe consequences for any action they deem in violation of this Code of Conduct:\n\n### 1. Correction\n\n**Community Impact**: Use of inappropriate language or other behavior deemed\nunprofessional or unwelcome in the community.\n\n**Consequence**: A private, written warning from community leaders, providing\nclarity around the nature of the violation and an explanation of why the\nbehavior was inappropriate. A public apology may be requested.\n\n### 2. Warning\n\n**Community Impact**: A violation through a single incident or series\nof actions.\n\n**Consequence**: A warning with consequences for continued behavior. No\ninteraction with the people involved, including unsolicited interaction with\nthose enforcing the Code of Conduct, for a specified period of time. This\nincludes avoiding interactions in community spaces as well as external channels\nlike social media. Violating these terms may lead to a temporary or\npermanent ban.\n\n### 3. Temporary Ban\n\n**Community Impact**: A serious violation of community standards, including\nsustained inappropriate behavior.\n\n**Consequence**: A temporary ban from any sort of interaction or public\ncommunication with the community for a specified period of time. No public or\nprivate interaction with the people involved, including unsolicited interaction\nwith those enforcing the Code of Conduct, is allowed during this period.\nViolating these terms may lead to a permanent ban.\n\n### 4. Permanent Ban\n\n**Community Impact**: Demonstrating a pattern of violation of community\nstandards, including sustained inappropriate behavior,  harassment of an\nindividual, or aggression toward or disparagement of classes of individuals.\n\n**Consequence**: A permanent ban from any sort of public interaction within\nthe community.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage],\nversion 2.0, available at\nhttps://www.contributor-covenant.org/version/2/0/code_of_conduct.html.\n\nCommunity Impact Guidelines were inspired by [Mozilla's code of conduct\nenforcement ladder](https://github.com/mozilla/diversity).\n\n[homepage]: https://www.contributor-covenant.org\n\nFor answers to common questions about this code of conduct, see the FAQ at\nhttps://www.contributor-covenant.org/faq. Translations are available at\nhttps://www.contributor-covenant.org/translations.\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to Project N.O.M.A.D.\n\nThank you for your interest in contributing to Project N.O.M.A.D.! Community contributions are what keep this project growing and improving. Please read this guide fully before getting started — it will save you (and the maintainers) a lot of time.\n\n> **Note:** Acceptance of contributions is not guaranteed. All pull requests are evaluated based on quality, relevance, and alignment with the project's goals. The maintainers of Project N.O.M.A.D. (\"Nomad\") reserve the right accept, deny, or modify any pull request at their sole discretion.\n\n---\n\n## Table of Contents\n\n- [Code of Conduct](#code-of-conduct)\n- [Before You Start](#before-you-start)\n- [Getting Started](#getting-started)\n- [Development Workflow](#development-workflow)\n- [Commit Messages](#commit-messages)\n- [Release Notes](#release-notes)\n- [Versioning](#versioning)\n- [Submitting a Pull Request](#submitting-a-pull-request)\n- [Feedback & Community](#feedback--community)\n\n---\n\n## Code of Conduct\n\nPlease read and review our full [Code of Conduct](https://github.com/Crosstalk-Solutions/project-nomad/blob/main/CODE_OF_CONDUCT.md) before contributing. In short: please be respectful and considerate in all interactions with maintainers and other contributors.\n\nWe are committed to providing a welcoming environment for everyone. Disrespectful or abusive behavior will not be tolerated. \n\n---\n\n## Before You Start\n\n**Open an issue first.** Before writing any code, please [open an issue](../../issues/new) to discuss your proposed change. This helps avoid duplicate work and ensures your contribution aligns with the project's direction.\n\nWhen opening an issue:\n- Use a clear, descriptive title\n- Describe the problem you're solving or the feature you want to add\n- If it's a bug, include steps to reproduce it and as much detail about your environment as possible\n- Ensure you redact any personal or sensitive information in any logs, configs, etc.\n\n---\n\n## Getting Started with Contributing\n**Please note**: this is the Getting Started guide for developing and contributing to Nomad, NOT [installing Nomad](https://github.com/Crosstalk-Solutions/project-nomad/blob/main/README.md) for regular use! \n\n### Prerequisites\n\n- A Debian-based OS (Ubuntu recommended)\n- `sudo`/root privileges\n- Docker installed and running\n- A stable internet connection (required for dependency downloads)\n- Node.js (for frontend/admin work)\n\n### Fork & Clone\n\n1. Click **Fork** at the top right of this repository\n2. Clone your fork locally:\n   ```bash\n   git clone https://github.com/YOUR_USERNAME/project-nomad.git\n   cd project-nomad\n   ```\n3. Add the upstream remote so you can stay in sync:\n   ```bash\n   git remote add upstream https://github.com/Crosstalk-Solutions/project-nomad.git\n   ```\n\n### Avoid Installing a Release Version Locally\nBecause Nomad relies heavily on Docker, we actually recommend against installing a release version of the project on the same local machine where you are developing. This can lead to conflicts with ports, volumes, and other resources. Instead, you can run your development version in a separate Docker environment while keeping your local machine clean. It certainly __can__ be done, but it adds complexity to your setup and workflow. If you choose to install a release version locally, please ensure you have a clear strategy for managing potential conflicts and resource usage.\n\n---\n\n## Development Workflow\n\n1. **Sync with upstream** before starting any new work. We prefer rebasing over merge commits to keep a clean, linear git history as much as possible (this also makes it easier for maintainers to review and merge your changes). To sync with upstream:\n   ```bash\n   git fetch upstream\n   git checkout main\n   git rebase upstream/main\n   ```\n\n2. **Create a feature branch** off `main` with a descriptive name:\n   ```bash\n   git checkout -b fix/issue-123\n   # or\n   git checkout -b feature/add-new-tool\n   ```\n\n3. **Make your changes.** Follow existing code style and conventions. Test your changes locally against a running N.O.M.A.D. instance before submitting.\n\n4. **Add release notes** (see [Release Notes](#release-notes) below).\n\n5. **Commit your changes** using [Conventional Commits](#commit-messages).\n\n6. **Push your branch** and open a pull request.\n\n---\n\n## Commit Messages\n\nThis project uses [Conventional Commits](https://www.conventionalcommits.org/). All commit messages must follow this format:\n\n```\n<type>(<scope>): <description>\n```\n\n**Common types:**\n\n| Type | When to use |\n|------|-------------|\n| `feat` | A new user-facing feature |\n| `fix` | A bug fix |\n| `docs` | Documentation changes only |\n| `refactor` | Code change that isn't a fix or feature and does not affect functionality |\n| `chore` | Build process, dependency updates, tooling |\n| `test` | Adding or updating tests |\n\n**Scope** is optional but encouraged — use it to indicate the area of the codebase affected (e.g., `api`, `ui`, `maps`).\n\n**Examples:**\n```\nfeat(ui): add dark mode toggle to Command Center\nfix(api): resolve container status not updating after restart\ndocs: update hardware requirements in README\nchore(deps): bump docker-compose to v2.24\n```\n\n---\n\n## Release Notes\n\nHuman-readable release notes live in [`admin/docs/release-notes.md`](admin/docs/release-notes.md) and are displayed directly in the Command Center UI.\n\nWhen your changes include anything user-facing, **add a summary to the `## Unreleased` section** at the top of that file under the appropriate heading:\n\n- **Features** — new user-facing capabilities\n- **Bug Fixes** — corrections to existing behavior\n- **Improvements** — enhancements, refactors, docs, or dependency updates\n\nUse the format `- **Area**: Description` to stay consistent with existing entries.\n\n**Example:**\n```markdown\n## Unreleased\n\n### Features\n- **Maps**: Added support for downloading South America regional maps\n\n### Bug Fixes\n- **AI Chat**: Fixed document upload failing on filenames with special characters\n```\n\n> When a release is triggered, CI automatically stamps the version and date, commits the update, and publishes the content to the GitHub release. You do not need to do this manually.\n\n---\n\n## Versioning\n\nThis project uses [Semantic Versioning](https://semver.org/). Versions are managed in the root `package.json` and updated automatically by `semantic-release`. The `project-nomad` Docker image uses this version. The `admin/package.json` version stays at `0.0.0` and should not be changed manually.\n\n---\n\n## Submitting a Pull Request\n\n1. Push your branch to your fork:\n   ```bash\n   git push origin your-branch-name\n   ```\n2. Open a pull request against the `main` branch of this repository\n3. In the PR description:\n   - Summarize what your changes do and why\n   - Reference the related issue (e.g., `Closes #123`)\n   - Note any relevant testing steps or environment details\n4. Be responsive to feedback — maintainers may request changes. Pull requests with no activity for an extended period may be closed.\n\n---\n\n## Feedback & Community\n\nHave questions or want to discuss ideas before opening an issue? Join the community:\n\n- **Discord:** [Join the Crosstalk Solutions server](https://discord.com/invite/crosstalksolutions) — the best place to get help, share your builds, and talk with other N.O.M.A.D. users\n- **Website:** [www.projectnomad.us](https://www.projectnomad.us)\n- **Benchmark Leaderboard:** [benchmark.projectnomad.us](https://benchmark.projectnomad.us)\n\n---\n\n*Project N.O.M.A.D. is licensed under the [Apache License 2.0](LICENSE).*"
  },
  {
    "path": "Dockerfile",
    "content": "FROM node:22-slim AS base\n\n# Install bash & curl for entrypoint script compatibility, graphicsmagick for pdf2pic, and vips-dev & build-base for sharp \nRUN apt-get update && apt-get install -y bash curl graphicsmagick libvips-dev build-essential\n\n# All deps stage\nFROM base AS deps\nWORKDIR /app\nADD admin/package.json admin/package-lock.json ./\nRUN npm ci\n\n# Production only deps stage\nFROM base AS production-deps\nWORKDIR /app\nADD admin/package.json admin/package-lock.json ./\nRUN npm ci --omit=dev\n\n# Build stage\nFROM base AS build\nWORKDIR /app\nCOPY --from=deps /app/node_modules /app/node_modules\nADD admin/ ./\nRUN node ace build\n\n# Production stage\nFROM base\nARG VERSION=dev\nARG BUILD_DATE\nARG VCS_REF\n\n# Labels\nLABEL org.opencontainers.image.title=\"Project N.O.M.A.D\" \\\n      org.opencontainers.image.description=\"The Project N.O.M.A.D Official Docker image\" \\\n      org.opencontainers.image.version=\"${VERSION}\" \\\n      org.opencontainers.image.created=\"${BUILD_DATE}\" \\\n      org.opencontainers.image.revision=\"${VCS_REF}\" \\\n      org.opencontainers.image.vendor=\"Crosstalk Solutions, LLC\" \\\n      org.opencontainers.image.documentation=\"https://github.com/CrosstalkSolutions/project-nomad/blob/main/README.md\" \\\n      org.opencontainers.image.source=\"https://github.com/CrosstalkSolutions/project-nomad\" \\\n      org.opencontainers.image.licenses=\"Apache-2.0\"\n\nENV NODE_ENV=production\nWORKDIR /app\nCOPY --from=production-deps /app/node_modules /app/node_modules\nCOPY --from=build /app/build /app\n# Copy root package.json for version info\nCOPY package.json /app/version.json\n\n# Copy docs and README for access within the container\nCOPY admin/docs /app/docs\nCOPY README.md /app/README.md\n\n# Copy entrypoint script and ensure it's executable\nCOPY install/entrypoint.sh /usr/local/bin/entrypoint.sh\nRUN chmod +x /usr/local/bin/entrypoint.sh\n\nEXPOSE 8080\nENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to the Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by the Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding any notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   Copyright 2024-2026 Crosstalk Solutions LLC\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n<img src=\"https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/admin/public/project_nomad_logo.png\" width=\"200\" height=\"200\"/>\n\n# Project N.O.M.A.D.\n### Node for Offline Media, Archives, and Data\n\n**Knowledge That Never Goes Offline**\n\n[![Website](https://img.shields.io/badge/Website-projectnomad.us-blue)](https://www.projectnomad.us)\n[![Discord](https://img.shields.io/badge/Discord-Join%20Community-5865F2)](https://discord.com/invite/crosstalksolutions)\n[![Benchmark](https://img.shields.io/badge/Benchmark-Leaderboard-green)](https://benchmark.projectnomad.us)\n\n</div>\n\n---\n\nProject N.O.M.A.D. is a self-contained, offline-first knowledge and education server packed with critical tools, knowledge, and AI to keep you informed and empowered—anytime, anywhere.\n\n## Installation & Quickstart\nProject N.O.M.A.D. can be installed on any Debian-based operating system (we recommend Ubuntu). Installation is completely terminal-based, and all tools and resources are designed to be accessed through the browser, so there's no need for a desktop environment if you'd rather setup N.O.M.A.D. as a \"server\" and access it through other clients.\n\n*Note: sudo/root privileges are required to run the install script*\n\n#### Quick Install (Debian-based OS Only)\n```bash\nsudo 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\n```\n\nProject 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!\n\n### Advanced Installation\nFor 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.\n\n## How It Works\nN.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\n**Built-in capabilities include:**\n- **AI Chat with Knowledge Base** — local AI chat powered by [Ollama](https://ollama.com/), with document upload and semantic search (RAG via [Qdrant](https://qdrant.tech/))\n- **Information Library** — offline Wikipedia, medical references, ebooks, and more via [Kiwix](https://kiwix.org/)\n- **Education Platform** — Khan Academy courses with progress tracking via [Kolibri](https://learningequality.org/kolibri/)\n- **Offline Maps** — downloadable regional maps via [ProtoMaps](https://protomaps.com)\n- **Data Tools** — encryption, encoding, and analysis via [CyberChef](https://gchq.github.io/CyberChef/)\n- **Notes** — local note-taking via [FlatNotes](https://github.com/dullage/flatnotes)\n- **System Benchmark** — hardware scoring with a [community leaderboard](https://benchmark.projectnomad.us)\n- **Easy Setup Wizard** — guided first-time configuration with curated content collections\n\nN.O.M.A.D. also includes built-in tools like a Wikipedia content selector, ZIM library manager, and content explorer.\n\n## What's Included\n\n| Capability | Powered By | What You Get |\n|-----------|-----------|-------------|\n| Information Library | Kiwix | Offline Wikipedia, medical references, survival guides, ebooks |\n| AI Assistant | Ollama + Qdrant | Built-in chat with document upload and semantic search |\n| Education Platform | Kolibri | Khan Academy courses, progress tracking, multi-user support |\n| Offline Maps | ProtoMaps | Downloadable regional maps with search and navigation |\n| Data Tools | CyberChef | Encryption, encoding, hashing, and data analysis |\n| Notes | FlatNotes | Local note-taking with markdown support |\n| System Benchmark | Built-in | Hardware scoring, Builder Tags, and community leaderboard |\n\n## Device Requirements\nWhile many similar offline survival computers are designed to be run on bare-minimum, lightweight hardware, Project N.O.M.A.D. is quite the opposite. To install and run the\navailable AI tools, we highly encourage the use of a beefy, GPU-backed device to make the most of your install.\n\nAt it's core, however, N.O.M.A.D. is still very lightweight. For a barebones installation of the management application itself, the following minimal specs are required:\n\n*Note: Project N.O.M.A.D. is not sponsored by any hardware manufacturer and is designed to be as hardware-agnostic as possible. The harware listed below is for example/comparison use only*\n\n#### Minimum Specs\n- Processor: 2 GHz dual-core processor or better\n- RAM: 4GB system memory\n- Storage: At least 5 GB free disk space\n- OS: Debian-based (Ubuntu recommended)\n- Stable internet connection (required during install only)\n\nTo run LLM's and other included AI tools:\n\n#### Optimal Specs\n- Processor: AMD Ryzen 7 or Intel Core i7 or better\n- RAM: 32 GB system memory\n- Graphics: NVIDIA RTX 3060 or AMD equivalent or better (more VRAM = run larger models)\n- Storage: At least 250 GB free disk space (preferably on SSD)\n- OS: Debian-based (Ubuntu recommended)\n- Stable internet connection (required during install only)\n\n**For detailed build recommendations at three price points ($150–$1,000+), see the [Hardware Guide](https://www.projectnomad.us/hardware).**\n\nAgain, 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\n\n## About Internet Usage & Privacy\nProject 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.\n\nTo test internet connectivity, N.O.M.A.D. attempts to make a request to Cloudflare's utility endpoint, `https://1.1.1.1/cdn-cgi/trace` and checks for a successful response.\n\n## About Security\nBy 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.\n\n**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.\n\n## Contributing\nContributions are welcome and appreciated! Please read this section fully to understand how to contribute to the project.\n\n### General Guidelines\n\n- **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.\n- **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.\n- **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`).\n- **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.\n- **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.\n- **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:\n  - `type` is the type of change (e.g., `feat` for new features, `fix` for bug fixes, `docs` for documentation changes, etc.)\n  - `scope` is an optional area of the codebase that your change affects (e.g., `api`, `ui`, `docs`, etc.)\n  - `description` is a brief summary of the change\n- **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.\n- **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.\n- **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.\n- **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.\n- **Thank you for contributing to Project N.O.M.A.D.!** Your efforts help make this project better for everyone.\n\n### Versioning\nThis project uses semantic versioning. The version is managed in the root `package.json` \nand automatically updated by semantic-release. For simplicity's sake, the \"project-nomad\" image\nuses 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.\n\n### Release Notes\nHuman-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.\n\nWhen working on changes, add a summary to the `## Unreleased` section at the top of that file under the appropriate heading:\n\n- **Features** — new user-facing capabilities\n- **Bug Fixes** — corrections to existing behavior\n- **Improvements** — enhancements, refactors, docs, or dependency updates\n\nUse 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.\n\n## Community & Resources\n\n- **Website:** [www.projectnomad.us](https://www.projectnomad.us) - Learn more about the project\n- **Discord:** [Join the Community](https://discord.com/invite/crosstalksolutions) - Get help, share your builds, and connect with other NOMAD users\n- **Benchmark Leaderboard:** [benchmark.projectnomad.us](https://benchmark.projectnomad.us) - See how your hardware stacks up against other NOMAD builds\n\n## License\n\nProject N.O.M.A.D. is licensed under the [Apache License 2.0](LICENSE).\n\n## Helper Scripts\nOnce installed, Project N.O.M.A.D. has a few helper scripts should you ever need to troubleshoot issues or perform maintenance that can't be done through the Command Center. All of these scripts are found in Project N.O.M.A.D.'s install directory, `/opt/project-nomad`\n\n###\n\n###### Start Script - Starts all installed project containers\n```bash\nsudo bash /opt/project-nomad/start_nomad.sh\n```\n###\n\n###### Stop Script - Stops all installed project containers\n```bash\nsudo bash /opt/project-nomad/stop_nomad.sh\n```\n###\n\n###### Update Script - Attempts to pull the latest images for the Command Center and its dependencies (i.e. mysql) and recreate the containers. Note: this *only* updates the Command Center containers. It does not update the installable application containers - that should be done through the Command Center UI\n```bash\nsudo bash /opt/project-nomad/update_nomad.sh\n```\n\n###### Uninstall Script - Need to start fresh? Use the uninstall script to make your life easy. Note: this cannot be undone!\n```bash\ncurl -fsSL https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/uninstall_nomad.sh -o uninstall_nomad.sh && sudo bash uninstall_nomad.sh\n```"
  },
  {
    "path": "admin/.editorconfig",
    "content": "# http://editorconfig.org\n\n[*]\nindent_style = space\nindent_size = 2\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\n\n[*.json]\ninsert_final_newline = unset\n\n[**.min.js]\nindent_style = unset\ninsert_final_newline = unset\n\n[MakeFile]\nindent_style = space\n\n[*.md]\ntrim_trailing_whitespace = false\n"
  },
  {
    "path": "admin/ace.js",
    "content": "/*\n|--------------------------------------------------------------------------\n| JavaScript entrypoint for running ace commands\n|--------------------------------------------------------------------------\n|\n| DO NOT MODIFY THIS FILE AS IT WILL BE OVERRIDDEN DURING THE BUILD\n| PROCESS.\n|\n| See docs.adonisjs.com/guides/typescript-build-process#creating-production-build\n|\n| Since, we cannot run TypeScript source code using \"node\" binary, we need\n| a JavaScript entrypoint to run ace commands.\n|\n| This file registers the \"ts-node/esm\" hook with the Node.js module system\n| and then imports the \"bin/console.ts\" file.\n|\n*/\n\n/**\n * Register hook to process TypeScript files using ts-node-maintained\n */\nimport 'ts-node-maintained/register/esm'\n\n/**\n * Import ace console entrypoint\n */\nawait import('./bin/console.js')\n"
  },
  {
    "path": "admin/adonisrc.ts",
    "content": "import { defineConfig } from '@adonisjs/core/app'\n\nexport default defineConfig({\n  /*\n  |--------------------------------------------------------------------------\n  | Experimental flags\n  |--------------------------------------------------------------------------\n  |\n  | The following features will be enabled by default in the next major release\n  | of AdonisJS. You can opt into them today to avoid any breaking changes\n  | during upgrade.\n  |\n  */\n  experimental: {\n    mergeMultipartFieldsAndFiles: true,\n    shutdownInReverseOrder: true,\n  },\n\n  /*\n  |--------------------------------------------------------------------------\n  | Commands\n  |--------------------------------------------------------------------------\n  |\n  | List of ace commands to register from packages. The application commands\n  | will be scanned automatically from the \"./commands\" directory.\n  |\n  */\n  commands: [() => import('@adonisjs/core/commands'), () => import('@adonisjs/lucid/commands')],\n\n  /*\n  |--------------------------------------------------------------------------\n  | Service providers\n  |--------------------------------------------------------------------------\n  |\n  | List of service providers to import and register when booting the\n  | application\n  |\n  */\n  providers: [\n    () => import('@adonisjs/core/providers/app_provider'),\n    () => import('@adonisjs/core/providers/hash_provider'),\n    {\n      file: () => import('@adonisjs/core/providers/repl_provider'),\n      environment: ['repl', 'test'],\n    },\n    () => import('@adonisjs/core/providers/vinejs_provider'),\n    () => import('@adonisjs/core/providers/edge_provider'),\n    () => import('@adonisjs/session/session_provider'),\n    () => import('@adonisjs/vite/vite_provider'),\n    () => import('@adonisjs/shield/shield_provider'),\n    () => import('@adonisjs/static/static_provider'),\n    () => import('@adonisjs/cors/cors_provider'),\n    () => import('@adonisjs/lucid/database_provider'),\n    () => import('@adonisjs/inertia/inertia_provider'),\n    () => import('@adonisjs/transmit/transmit_provider'),\n    () => import('#providers/map_static_provider')\n  ],\n\n  /*\n  |--------------------------------------------------------------------------\n  | Preloads\n  |--------------------------------------------------------------------------\n  |\n  | List of modules to import before starting the application.\n  |\n  */\n  preloads: [() => import('#start/routes'), () => import('#start/kernel')],\n\n  /*\n  |--------------------------------------------------------------------------\n  | Tests\n  |--------------------------------------------------------------------------\n  |\n  | List of test suites to organize tests by their type. Feel free to remove\n  | and add additional suites.\n  |\n  */\n  tests: {\n    suites: [\n      {\n        files: ['tests/unit/**/*.spec(.ts|.js)'],\n        name: 'unit',\n        timeout: 2000,\n      },\n      {\n        files: ['tests/functional/**/*.spec(.ts|.js)'],\n        name: 'functional',\n        timeout: 30000,\n      },\n    ],\n    forceExit: false,\n  },\n\n  /*\n  |--------------------------------------------------------------------------\n  | Metafiles\n  |--------------------------------------------------------------------------\n  |\n  | A collection of files you want to copy to the build folder when creating\n  | the production build.\n  |\n  */\n  metaFiles: [\n    {\n      pattern: 'resources/views/**/*.edge',\n      reloadServer: false,\n    },\n    {\n      pattern: 'public/**',\n      reloadServer: false,\n    },\n  ],\n\n  assetsBundler: false,\n  hooks: {\n    onBuildStarting: [() => import('@adonisjs/vite/build_hook')],\n  },\n})\n"
  },
  {
    "path": "admin/app/controllers/benchmark_controller.ts",
    "content": "import { inject } from '@adonisjs/core'\nimport type { HttpContext } from '@adonisjs/core/http'\nimport { BenchmarkService } from '#services/benchmark_service'\nimport { runBenchmarkValidator, submitBenchmarkValidator } from '#validators/benchmark'\nimport { RunBenchmarkJob } from '#jobs/run_benchmark_job'\nimport type { BenchmarkType } from '../../types/benchmark.js'\nimport { randomUUID } from 'node:crypto'\n\n@inject()\nexport default class BenchmarkController {\n  constructor(private benchmarkService: BenchmarkService) {}\n\n  /**\n   * Start a benchmark run (async via job queue, or sync if specified)\n   */\n  async run({ request, response }: HttpContext) {\n    const payload = await request.validateUsing(runBenchmarkValidator)\n    const benchmarkType: BenchmarkType = payload.benchmark_type || 'full'\n    const runSync = request.input('sync') === 'true' || request.input('sync') === true\n\n    // Check if a benchmark is already running\n    const status = this.benchmarkService.getStatus()\n    if (status.status !== 'idle') {\n      return response.status(409).send({\n        success: false,\n        error: 'A benchmark is already running',\n        current_benchmark_id: status.benchmarkId,\n      })\n    }\n\n    // Run synchronously if requested (useful for local dev without Redis)\n    if (runSync) {\n      try {\n        let result\n        switch (benchmarkType) {\n          case 'full':\n            result = await this.benchmarkService.runFullBenchmark()\n            break\n          case 'system':\n            result = await this.benchmarkService.runSystemBenchmarks()\n            break\n          case 'ai':\n            result = await this.benchmarkService.runAIBenchmark()\n            break\n          default:\n            result = await this.benchmarkService.runFullBenchmark()\n        }\n        return response.send({\n          success: true,\n          benchmark_id: result.benchmark_id,\n          nomad_score: result.nomad_score,\n          result,\n        })\n      } catch (error) {\n        return response.status(500).send({\n          success: false,\n          error: error.message,\n        })\n      }\n    }\n\n    // Generate benchmark ID and dispatch job (async)\n    const benchmarkId = randomUUID()\n    const { job, created } = await RunBenchmarkJob.dispatch({\n      benchmark_id: benchmarkId,\n      benchmark_type: benchmarkType,\n      include_ai: benchmarkType === 'full' || benchmarkType === 'ai',\n    })\n\n    return response.status(201).send({\n      success: true,\n      job_id: job?.id || benchmarkId,\n      benchmark_id: benchmarkId,\n      message: created\n        ? `${benchmarkType} benchmark started`\n        : 'Benchmark job already exists',\n    })\n  }\n\n  /**\n   * Run a system-only benchmark (CPU, memory, disk)\n   */\n  async runSystem({ response }: HttpContext) {\n    const status = this.benchmarkService.getStatus()\n    if (status.status !== 'idle') {\n      return response.status(409).send({\n        success: false,\n        error: 'A benchmark is already running',\n      })\n    }\n\n    const benchmarkId = randomUUID()\n    await RunBenchmarkJob.dispatch({\n      benchmark_id: benchmarkId,\n      benchmark_type: 'system',\n      include_ai: false,\n    })\n\n    return response.status(201).send({\n      success: true,\n      benchmark_id: benchmarkId,\n      message: 'System benchmark started',\n    })\n  }\n\n  /**\n   * Run an AI-only benchmark\n   */\n  async runAI({ response }: HttpContext) {\n    const status = this.benchmarkService.getStatus()\n    if (status.status !== 'idle') {\n      return response.status(409).send({\n        success: false,\n        error: 'A benchmark is already running',\n      })\n    }\n\n    const benchmarkId = randomUUID()\n    await RunBenchmarkJob.dispatch({\n      benchmark_id: benchmarkId,\n      benchmark_type: 'ai',\n      include_ai: true,\n    })\n\n    return response.status(201).send({\n      success: true,\n      benchmark_id: benchmarkId,\n      message: 'AI benchmark started',\n    })\n  }\n\n  /**\n   * Get all benchmark results\n   */\n  async results({}: HttpContext) {\n    const results = await this.benchmarkService.getAllResults()\n    return {\n      results,\n      total: results.length,\n    }\n  }\n\n  /**\n   * Get the latest benchmark result\n   */\n  async latest({}: HttpContext) {\n    const result = await this.benchmarkService.getLatestResult()\n    if (!result) {\n      return { result: null }\n    }\n    return { result }\n  }\n\n  /**\n   * Get a specific benchmark result by ID\n   */\n  async show({ params, response }: HttpContext) {\n    const result = await this.benchmarkService.getResultById(params.id)\n    if (!result) {\n      return response.status(404).send({\n        error: 'Benchmark result not found',\n      })\n    }\n    return { result }\n  }\n\n  /**\n   * Submit benchmark results to central repository\n   */\n  async submit({ request, response }: HttpContext) {\n    const payload = await request.validateUsing(submitBenchmarkValidator)\n    const anonymous = request.input('anonymous') === true || request.input('anonymous') === 'true'\n\n    try {\n      const submitResult = await this.benchmarkService.submitToRepository(payload.benchmark_id, anonymous)\n      return response.send({\n        success: true,\n        repository_id: submitResult.repository_id,\n        percentile: submitResult.percentile,\n      })\n    } catch (error) {\n      // Pass through the status code from the service if available, otherwise default to 400\n      const statusCode = (error as any).statusCode || 400\n      return response.status(statusCode).send({\n        success: false,\n        error: error.message,\n      })\n    }\n  }\n\n  /**\n   * Update builder tag for a benchmark result\n   */\n  async updateBuilderTag({ request, response }: HttpContext) {\n    const benchmarkId = request.input('benchmark_id')\n    const builderTag = request.input('builder_tag')\n\n    if (!benchmarkId) {\n      return response.status(400).send({\n        success: false,\n        error: 'benchmark_id is required',\n      })\n    }\n\n    const result = await this.benchmarkService.getResultById(benchmarkId)\n    if (!result) {\n      return response.status(404).send({\n        success: false,\n        error: 'Benchmark result not found',\n      })\n    }\n\n    // Validate builder tag format if provided\n    if (builderTag) {\n      const tagPattern = /^[A-Za-z]+-[A-Za-z]+-\\d{4}$/\n      if (!tagPattern.test(builderTag)) {\n        return response.status(400).send({\n          success: false,\n          error: 'Invalid builder tag format. Expected: Word-Word-0000',\n        })\n      }\n    }\n\n    result.builder_tag = builderTag || null\n    await result.save()\n\n    return response.send({\n      success: true,\n      builder_tag: result.builder_tag,\n    })\n  }\n\n  /**\n   * Get comparison stats from central repository\n   */\n  async comparison({}: HttpContext) {\n    const stats = await this.benchmarkService.getComparisonStats()\n    return { stats }\n  }\n\n  /**\n   * Get current benchmark status\n   */\n  async status({}: HttpContext) {\n    return this.benchmarkService.getStatus()\n  }\n\n  /**\n   * Get benchmark settings\n   */\n  async settings({}: HttpContext) {\n    const { default: BenchmarkSetting } = await import('#models/benchmark_setting')\n    return await BenchmarkSetting.getAllSettings()\n  }\n\n  /**\n   * Update benchmark settings\n   */\n  async updateSettings({ request, response }: HttpContext) {\n    const { default: BenchmarkSetting } = await import('#models/benchmark_setting')\n    const body = request.body()\n\n    if (body.allow_anonymous_submission !== undefined) {\n      await BenchmarkSetting.setValue(\n        'allow_anonymous_submission',\n        body.allow_anonymous_submission ? 'true' : 'false'\n      )\n    }\n\n    return response.send({\n      success: true,\n      settings: await BenchmarkSetting.getAllSettings(),\n    })\n  }\n}\n"
  },
  {
    "path": "admin/app/controllers/chats_controller.ts",
    "content": "import { inject } from '@adonisjs/core'\nimport type { HttpContext } from '@adonisjs/core/http'\nimport { ChatService } from '#services/chat_service'\nimport { createSessionSchema, updateSessionSchema, addMessageSchema } from '#validators/chat'\nimport KVStore from '#models/kv_store'\nimport { SystemService } from '#services/system_service'\nimport { SERVICE_NAMES } from '../../constants/service_names.js'\n\n@inject()\nexport default class ChatsController {\n  constructor(private chatService: ChatService, private systemService: SystemService) {}\n\n  async inertia({ inertia, response }: HttpContext) {\n    const aiAssistantInstalled = await this.systemService.checkServiceInstalled(SERVICE_NAMES.OLLAMA)\n    if (!aiAssistantInstalled) {\n      return response.status(404).json({ error: 'AI Assistant service not installed' })\n    }\n    \n    const chatSuggestionsEnabled = await KVStore.getValue('chat.suggestionsEnabled')\n    return inertia.render('chat', {\n      settings: {\n        chatSuggestionsEnabled: chatSuggestionsEnabled ?? false,\n      },\n    })\n  }\n\n  async index({}: HttpContext) {\n    return await this.chatService.getAllSessions()\n  }\n\n  async show({ params, response }: HttpContext) {\n    const sessionId = parseInt(params.id)\n    const session = await this.chatService.getSession(sessionId)\n\n    if (!session) {\n      return response.status(404).json({ error: 'Session not found' })\n    }\n\n    return session\n  }\n\n  async store({ request, response }: HttpContext) {\n    try {\n      const data = await request.validateUsing(createSessionSchema)\n      const session = await this.chatService.createSession(data.title, data.model)\n      return response.status(201).json(session)\n    } catch (error) {\n      return response.status(500).json({\n        error: error instanceof Error ? error.message : 'Failed to create session',\n      })\n    }\n  }\n\n  async suggestions({ response }: HttpContext) {\n    try {\n      const suggestions = await this.chatService.getChatSuggestions()\n      return response.status(200).json({ suggestions })\n    } catch (error) {\n      return response.status(500).json({\n        error: error instanceof Error ? error.message : 'Failed to get suggestions',\n      })\n    }\n  }\n\n  async update({ params, request, response }: HttpContext) {\n    try {\n      const sessionId = parseInt(params.id)\n      const data = await request.validateUsing(updateSessionSchema)\n      const session = await this.chatService.updateSession(sessionId, data)\n      return session\n    } catch (error) {\n      return response.status(500).json({\n        error: error instanceof Error ? error.message : 'Failed to update session',\n      })\n    }\n  }\n\n  async destroy({ params, response }: HttpContext) {\n    try {\n      const sessionId = parseInt(params.id)\n      await this.chatService.deleteSession(sessionId)\n      return response.status(204)\n    } catch (error) {\n      return response.status(500).json({\n        error: error instanceof Error ? error.message : 'Failed to delete session',\n      })\n    }\n  }\n\n  async addMessage({ params, request, response }: HttpContext) {\n    try {\n      const sessionId = parseInt(params.id)\n      const data = await request.validateUsing(addMessageSchema)\n      const message = await this.chatService.addMessage(sessionId, data.role, data.content)\n      return response.status(201).json(message)\n    } catch (error) {\n      return response.status(500).json({\n        error: error instanceof Error ? error.message : 'Failed to add message',\n      })\n    }\n  }\n\n  async destroyAll({ response }: HttpContext) {\n    try {\n      const result = await this.chatService.deleteAllSessions()\n      return response.status(200).json(result)\n    } catch (error) {\n      return response.status(500).json({\n        error: error instanceof Error ? error.message : 'Failed to delete all sessions',\n      })\n    }\n  }\n}\n"
  },
  {
    "path": "admin/app/controllers/collection_updates_controller.ts",
    "content": "import { CollectionUpdateService } from '#services/collection_update_service'\nimport {\n  assertNotPrivateUrl,\n  applyContentUpdateValidator,\n  applyAllContentUpdatesValidator,\n} from '#validators/common'\nimport type { HttpContext } from '@adonisjs/core/http'\n\nexport default class CollectionUpdatesController {\n  async checkForUpdates({}: HttpContext) {\n    const service = new CollectionUpdateService()\n    return await service.checkForUpdates()\n  }\n\n  async applyUpdate({ request }: HttpContext) {\n    const update = await request.validateUsing(applyContentUpdateValidator)\n    assertNotPrivateUrl(update.download_url)\n    const service = new CollectionUpdateService()\n    return await service.applyUpdate(update)\n  }\n\n  async applyAllUpdates({ request }: HttpContext) {\n    const { updates } = await request.validateUsing(applyAllContentUpdatesValidator)\n    for (const update of updates) {\n      assertNotPrivateUrl(update.download_url)\n    }\n    const service = new CollectionUpdateService()\n    return await service.applyAllUpdates(updates)\n  }\n}\n"
  },
  {
    "path": "admin/app/controllers/docs_controller.ts",
    "content": "import { DocsService } from '#services/docs_service'\nimport { inject } from '@adonisjs/core'\nimport type { HttpContext } from '@adonisjs/core/http'\n\n@inject()\nexport default class DocsController {\n    constructor(\n        private docsService: DocsService\n    ) { }\n\n    async list({ }: HttpContext) {\n        return await this.docsService.getDocs();\n    }\n\n    async show({ params, inertia }: HttpContext) {\n        const content = await this.docsService.parseFile(params.slug);\n        return inertia.render('docs/show', {\n            content,\n        });\n    }\n}"
  },
  {
    "path": "admin/app/controllers/downloads_controller.ts",
    "content": "import type { HttpContext } from '@adonisjs/core/http'\nimport { DownloadService } from '#services/download_service'\nimport { downloadJobsByFiletypeSchema } from '#validators/download'\nimport { inject } from '@adonisjs/core'\n\n@inject()\nexport default class DownloadsController {\n  constructor(private downloadService: DownloadService) {}\n\n  async index() {\n    return this.downloadService.listDownloadJobs()\n  }\n\n  async filetype({ request }: HttpContext) {\n    const payload = await request.validateUsing(downloadJobsByFiletypeSchema)\n    return this.downloadService.listDownloadJobs(payload.params.filetype)\n  }\n\n  async removeJob({ params }: HttpContext) {\n    await this.downloadService.removeFailedJob(params.jobId)\n    return { success: true }\n  }\n}\n"
  },
  {
    "path": "admin/app/controllers/easy_setup_controller.ts",
    "content": "import { SystemService } from '#services/system_service'\nimport { ZimService } from '#services/zim_service'\nimport { CollectionManifestService } from '#services/collection_manifest_service'\nimport { inject } from '@adonisjs/core'\nimport type { HttpContext } from '@adonisjs/core/http'\n\n@inject()\nexport default class EasySetupController {\n  constructor(\n    private systemService: SystemService,\n    private zimService: ZimService\n  ) {}\n\n  async index({ inertia }: HttpContext) {\n    const services = await this.systemService.getServices({ installedOnly: false })\n    return inertia.render('easy-setup/index', {\n      system: {\n        services: services,\n      },\n    })\n  }\n\n  async complete({ inertia }: HttpContext) {\n    return inertia.render('easy-setup/complete')\n  }\n\n  async listCuratedCategories({}: HttpContext) {\n    return await this.zimService.listCuratedCategories()\n  }\n\n  async refreshManifests({}: HttpContext) {\n    const manifestService = new CollectionManifestService()\n    const [zimChanged, mapsChanged, wikiChanged] = await Promise.all([\n      manifestService.fetchAndCacheSpec('zim_categories'),\n      manifestService.fetchAndCacheSpec('maps'),\n      manifestService.fetchAndCacheSpec('wikipedia'),\n    ])\n\n    return {\n      success: true,\n      changed: {\n        zim_categories: zimChanged,\n        maps: mapsChanged,\n        wikipedia: wikiChanged,\n      },\n    }\n  }\n}\n"
  },
  {
    "path": "admin/app/controllers/home_controller.ts",
    "content": "import { SystemService } from '#services/system_service'\nimport { inject } from '@adonisjs/core'\nimport type { HttpContext } from '@adonisjs/core/http'\n\n@inject()\nexport default class HomeController {\n    constructor(\n        private systemService: SystemService,\n    ) { }\n\n    async index({ response }: HttpContext) {\n        // Redirect / to /home\n        return response.redirect().toPath('/home');\n    }\n\n    async home({ inertia }: HttpContext) {\n        const services = await this.systemService.getServices({ installedOnly: true });\n        return inertia.render('home', {\n            system: {\n                services\n            }\n        })\n    }\n}"
  },
  {
    "path": "admin/app/controllers/maps_controller.ts",
    "content": "import { MapService } from '#services/map_service'\nimport {\n  assertNotPrivateUrl,\n  downloadCollectionValidator,\n  filenameParamValidator,\n  remoteDownloadValidator,\n  remoteDownloadValidatorOptional,\n} from '#validators/common'\nimport { inject } from '@adonisjs/core'\nimport type { HttpContext } from '@adonisjs/core/http'\n\n@inject()\nexport default class MapsController {\n  constructor(private mapService: MapService) {}\n\n  async index({ inertia }: HttpContext) {\n    const baseAssetsCheck = await this.mapService.ensureBaseAssets()\n    const regionFiles = await this.mapService.listRegions()\n    return inertia.render('maps', {\n      maps: {\n        baseAssetsExist: baseAssetsCheck,\n        regionFiles: regionFiles.files,\n      },\n    })\n  }\n\n  async downloadBaseAssets({ request }: HttpContext) {\n    const payload = await request.validateUsing(remoteDownloadValidatorOptional)\n    if (payload.url) assertNotPrivateUrl(payload.url)\n    await this.mapService.downloadBaseAssets(payload.url)\n    return { success: true }\n  }\n\n  async downloadRemote({ request }: HttpContext) {\n    const payload = await request.validateUsing(remoteDownloadValidator)\n    assertNotPrivateUrl(payload.url)\n    const filename = await this.mapService.downloadRemote(payload.url)\n    return {\n      message: 'Download started successfully',\n      filename,\n      url: payload.url,\n    }\n  }\n\n  async downloadCollection({ request }: HttpContext) {\n    const payload = await request.validateUsing(downloadCollectionValidator)\n    const resources = await this.mapService.downloadCollection(payload.slug)\n    return {\n      message: 'Collection download started successfully',\n      slug: payload.slug,\n      resources,\n    }\n  }\n\n  // For providing a \"preflight\" check in the UI before actually starting a background download\n  async downloadRemotePreflight({ request }: HttpContext) {\n    const payload = await request.validateUsing(remoteDownloadValidator)\n    assertNotPrivateUrl(payload.url)\n    const info = await this.mapService.downloadRemotePreflight(payload.url)\n    return info\n  }\n\n  async fetchLatestCollections({}: HttpContext) {\n    const success = await this.mapService.fetchLatestCollections()\n    return { success }\n  }\n\n  async listCuratedCollections({}: HttpContext) {\n    return await this.mapService.listCuratedCollections()\n  }\n\n  async listRegions({}: HttpContext) {\n    return await this.mapService.listRegions()\n  }\n\n  async styles({ request, response }: HttpContext) {\n    // Automatically ensure base assets are present before generating styles\n    const baseAssetsExist = await this.mapService.ensureBaseAssets()\n    if (!baseAssetsExist) {\n      return response.status(500).send({\n        message:\n          'Base map assets are missing and could not be downloaded. Please check your connection and try again.',\n      })\n    }\n\n    const styles = await this.mapService.generateStylesJSON(request.host(), request.protocol())\n    return response.json(styles)\n  }\n\n  async delete({ request, response }: HttpContext) {\n    const payload = await request.validateUsing(filenameParamValidator)\n\n    try {\n      await this.mapService.delete(payload.params.filename)\n    } catch (error) {\n      if (error.message === 'not_found') {\n        return response.status(404).send({\n          message: `Map file with key ${payload.params.filename} not found`,\n        })\n      }\n      throw error // Re-throw any other errors and let the global error handler catch\n    }\n\n    return {\n      message: 'Map file deleted successfully',\n    }\n  }\n}\n"
  },
  {
    "path": "admin/app/controllers/ollama_controller.ts",
    "content": "import { ChatService } from '#services/chat_service'\nimport { OllamaService } from '#services/ollama_service'\nimport { RagService } from '#services/rag_service'\nimport { modelNameSchema } from '#validators/download'\nimport { chatSchema, getAvailableModelsSchema } from '#validators/ollama'\nimport { inject } from '@adonisjs/core'\nimport type { HttpContext } from '@adonisjs/core/http'\nimport { DEFAULT_QUERY_REWRITE_MODEL, RAG_CONTEXT_LIMITS, SYSTEM_PROMPTS } from '../../constants/ollama.js'\nimport logger from '@adonisjs/core/services/logger'\nimport type { Message } from 'ollama'\n\n@inject()\nexport default class OllamaController {\n  constructor(\n    private chatService: ChatService,\n    private ollamaService: OllamaService,\n    private ragService: RagService\n  ) { }\n\n  async availableModels({ request }: HttpContext) {\n    const reqData = await request.validateUsing(getAvailableModelsSchema)\n    return await this.ollamaService.getAvailableModels({\n      sort: reqData.sort,\n      recommendedOnly: reqData.recommendedOnly,\n      query: reqData.query || null,\n      limit: reqData.limit || 15,\n      force: reqData.force,\n    })\n  }\n\n  async chat({ request, response }: HttpContext) {\n    const reqData = await request.validateUsing(chatSchema)\n\n    // Flush SSE headers immediately so the client connection is open while\n    // pre-processing (query rewriting, RAG lookup) runs in the background.\n    if (reqData.stream) {\n      response.response.setHeader('Content-Type', 'text/event-stream')\n      response.response.setHeader('Cache-Control', 'no-cache')\n      response.response.setHeader('Connection', 'keep-alive')\n      response.response.flushHeaders()\n    }\n\n    try {\n      // If there are no system messages in the chat inject system prompts\n      const hasSystemMessage = reqData.messages.some((msg) => msg.role === 'system')\n      if (!hasSystemMessage) {\n        const systemPrompt = {\n          role: 'system' as const,\n          content: SYSTEM_PROMPTS.default,\n        }\n        logger.debug('[OllamaController] Injecting system prompt')\n        reqData.messages.unshift(systemPrompt)\n      }\n\n      // Query rewriting for better RAG retrieval with manageable context\n      // Will return user's latest message if no rewriting is needed\n      const rewrittenQuery = await this.rewriteQueryWithContext(reqData.messages)\n\n      logger.debug(`[OllamaController] Rewritten query for RAG: \"${rewrittenQuery}\"`)\n      if (rewrittenQuery) {\n        const relevantDocs = await this.ragService.searchSimilarDocuments(\n          rewrittenQuery,\n          5, // Top 5 most relevant chunks\n          0.3 // Minimum similarity score of 0.3\n        )\n\n        logger.debug(`[RAG] Retrieved ${relevantDocs.length} relevant documents for query: \"${rewrittenQuery}\"`)\n\n        // If relevant context is found, inject as a system message with adaptive limits\n        if (relevantDocs.length > 0) {\n          // Determine context budget based on model size\n          const { maxResults, maxTokens } = this.getContextLimitsForModel(reqData.model)\n          let trimmedDocs = relevantDocs.slice(0, maxResults)\n\n          // Apply token cap if set (estimate ~4 chars per token)\n          // Always include the first (most relevant) result — the cap only gates subsequent results\n          if (maxTokens > 0) {\n            const charCap = maxTokens * 4\n            let totalChars = 0\n            trimmedDocs = trimmedDocs.filter((doc, idx) => {\n              totalChars += doc.text.length\n              return idx === 0 || totalChars <= charCap\n            })\n          }\n\n          logger.debug(\n            `[RAG] Injecting ${trimmedDocs.length}/${relevantDocs.length} results (model: ${reqData.model}, maxResults: ${maxResults}, maxTokens: ${maxTokens || 'unlimited'})`\n          )\n\n          const contextText = trimmedDocs\n            .map((doc, idx) => `[Context ${idx + 1}] (Relevance: ${(doc.score * 100).toFixed(1)}%)\\n${doc.text}`)\n            .join('\\n\\n')\n\n          const systemMessage = {\n            role: 'system' as const,\n            content: SYSTEM_PROMPTS.rag_context(contextText),\n          }\n\n          // Insert system message at the beginning (after any existing system messages)\n          const firstNonSystemIndex = reqData.messages.findIndex((msg) => msg.role !== 'system')\n          const insertIndex = firstNonSystemIndex === -1 ? 0 : firstNonSystemIndex\n          reqData.messages.splice(insertIndex, 0, systemMessage)\n        }\n      }\n\n      // Check if the model supports \"thinking\" capability for enhanced response generation\n      // If gpt-oss model, it requires a text param for \"think\" https://docs.ollama.com/api/chat\n      const thinkingCapability = await this.ollamaService.checkModelHasThinking(reqData.model)\n      const think: boolean | 'medium' = thinkingCapability ? (reqData.model.startsWith('gpt-oss') ? 'medium' : true) : false\n\n      // Separate sessionId from the Ollama request payload — Ollama rejects unknown fields\n      const { sessionId, ...ollamaRequest } = reqData\n\n      // Save user message to DB before streaming if sessionId provided\n      let userContent: string | null = null\n      if (sessionId) {\n        const lastUserMsg = [...reqData.messages].reverse().find((m) => m.role === 'user')\n        if (lastUserMsg) {\n          userContent = lastUserMsg.content\n          await this.chatService.addMessage(sessionId, 'user', userContent)\n        }\n      }\n\n      if (reqData.stream) {\n        logger.debug(`[OllamaController] Initiating streaming response for model: \"${reqData.model}\" with think: ${think}`)\n        // Headers already flushed above\n        const stream = await this.ollamaService.chatStream({ ...ollamaRequest, think })\n        let fullContent = ''\n        for await (const chunk of stream) {\n          if (chunk.message?.content) {\n            fullContent += chunk.message.content\n          }\n          response.response.write(`data: ${JSON.stringify(chunk)}\\n\\n`)\n        }\n        response.response.end()\n\n        // Save assistant message and optionally generate title\n        if (sessionId && fullContent) {\n          await this.chatService.addMessage(sessionId, 'assistant', fullContent)\n          const messageCount = await this.chatService.getMessageCount(sessionId)\n          if (messageCount <= 2 && userContent) {\n            this.chatService.generateTitle(sessionId, userContent, fullContent).catch((err) => {\n              logger.error(`[OllamaController] Title generation failed: ${err instanceof Error ? err.message : err}`)\n            })\n          }\n        }\n        return\n      }\n\n      // Non-streaming (legacy) path\n      const result = await this.ollamaService.chat({ ...ollamaRequest, think })\n\n      if (sessionId && result?.message?.content) {\n        await this.chatService.addMessage(sessionId, 'assistant', result.message.content)\n        const messageCount = await this.chatService.getMessageCount(sessionId)\n        if (messageCount <= 2 && userContent) {\n          this.chatService.generateTitle(sessionId, userContent, result.message.content).catch((err) => {\n            logger.error(`[OllamaController] Title generation failed: ${err instanceof Error ? err.message : err}`)\n          })\n        }\n      }\n\n      return result\n    } catch (error) {\n      if (reqData.stream) {\n        response.response.write(`data: ${JSON.stringify({ error: true })}\\n\\n`)\n        response.response.end()\n        return\n      }\n      throw error\n    }\n  }\n\n  async deleteModel({ request }: HttpContext) {\n    const reqData = await request.validateUsing(modelNameSchema)\n    await this.ollamaService.deleteModel(reqData.model)\n    return {\n      success: true,\n      message: `Model deleted: ${reqData.model}`,\n    }\n  }\n\n  async dispatchModelDownload({ request }: HttpContext) {\n    const reqData = await request.validateUsing(modelNameSchema)\n    await this.ollamaService.dispatchModelDownload(reqData.model)\n    return {\n      success: true,\n      message: `Download job dispatched for model: ${reqData.model}`,\n    }\n  }\n\n  async installedModels({ }: HttpContext) {\n    return await this.ollamaService.getModels()\n  }\n\n  /**\n   * Determines RAG context limits based on model size extracted from the model name.\n   * Parses size indicators like \"1b\", \"3b\", \"8b\", \"70b\" from model names/tags.\n   */\n  private getContextLimitsForModel(modelName: string): { maxResults: number; maxTokens: number } {\n    // Extract parameter count from model name (e.g., \"llama3.2:3b\", \"qwen2.5:1.5b\", \"gemma:7b\")\n    const sizeMatch = modelName.match(/(\\d+\\.?\\d*)[bB]/)\n    const paramBillions = sizeMatch ? parseFloat(sizeMatch[1]) : 8 // default to 8B if unknown\n\n    for (const tier of RAG_CONTEXT_LIMITS) {\n      if (paramBillions <= tier.maxParams) {\n        return { maxResults: tier.maxResults, maxTokens: tier.maxTokens }\n      }\n    }\n\n    // Fallback: no limits\n    return { maxResults: 5, maxTokens: 0 }\n  }\n\n  private async rewriteQueryWithContext(\n    messages: Message[]\n  ): Promise<string | null> {\n    try {\n      // Get recent conversation history (last 6 messages for 3 turns)\n      const recentMessages = messages.slice(-6)\n\n      // Skip rewriting for short conversations. Rewriting adds latency with\n      // little RAG benefit until there is enough context to matter.\n      const userMessages = recentMessages.filter(msg => msg.role === 'user')\n      if (userMessages.length <= 2) {\n        return userMessages[userMessages.length - 1]?.content || null\n      }\n\n      const conversationContext = recentMessages\n        .map(msg => {\n          const role = msg.role === 'user' ? 'User' : 'Assistant'\n          // Truncate assistant messages to first 200 chars to keep context manageable\n          const content = msg.role === 'assistant'\n            ? msg.content.slice(0, 200) + (msg.content.length > 200 ? '...' : '')\n            : msg.content\n          return `${role}: \"${content}\"`\n        })\n        .join('\\n')\n\n      const installedModels = await this.ollamaService.getModels(true)\n      const rewriteModelAvailable = installedModels?.some(model => model.name === DEFAULT_QUERY_REWRITE_MODEL)\n      if (!rewriteModelAvailable) {\n        logger.warn(`[RAG] Query rewrite model \"${DEFAULT_QUERY_REWRITE_MODEL}\" not available. Skipping query rewriting.`)\n        const lastUserMessage = [...messages].reverse().find(msg => msg.role === 'user')\n        return lastUserMessage?.content || null\n      }\n\n      // FUTURE ENHANCEMENT: allow the user to specify which model to use for rewriting\n      const response = await this.ollamaService.chat({\n        model: DEFAULT_QUERY_REWRITE_MODEL,\n        messages: [\n          {\n            role: 'system',\n            content: SYSTEM_PROMPTS.query_rewrite,\n          },\n          {\n            role: 'user',\n            content: `Conversation:\\n${conversationContext}\\n\\nRewritten Query:`,\n          },\n        ],\n      })\n\n      const rewrittenQuery = response.message.content.trim()\n      logger.info(`[RAG] Query rewritten: \"${rewrittenQuery}\"`)\n      return rewrittenQuery\n    } catch (error) {\n      logger.error(\n        `[RAG] Query rewriting failed: ${error instanceof Error ? error.message : error}`\n      )\n      // Fallback to last user message if rewriting fails\n      const lastUserMessage = [...messages].reverse().find(msg => msg.role === 'user')\n      return lastUserMessage?.content || null\n    }\n  }\n}\n"
  },
  {
    "path": "admin/app/controllers/rag_controller.ts",
    "content": "import { RagService } from '#services/rag_service'\nimport { EmbedFileJob } from '#jobs/embed_file_job'\nimport { inject } from '@adonisjs/core'\nimport type { HttpContext } from '@adonisjs/core/http'\nimport app from '@adonisjs/core/services/app'\nimport { randomBytes } from 'node:crypto'\nimport { sanitizeFilename } from '../utils/fs.js'\nimport { deleteFileSchema, getJobStatusSchema } from '#validators/rag'\n\n@inject()\nexport default class RagController {\n  constructor(private ragService: RagService) { }\n\n  public async upload({ request, response }: HttpContext) {\n    const uploadedFile = request.file('file')\n    if (!uploadedFile) {\n      return response.status(400).json({ error: 'No file uploaded' })\n    }\n\n    const randomSuffix = randomBytes(6).toString('hex')\n    const sanitizedName = sanitizeFilename(uploadedFile.clientName)\n\n    const fileName = `${sanitizedName}-${randomSuffix}.${uploadedFile.extname || 'txt'}`\n    const fullPath = app.makePath(RagService.UPLOADS_STORAGE_PATH, fileName)\n\n    await uploadedFile.move(app.makePath(RagService.UPLOADS_STORAGE_PATH), {\n      name: fileName,\n    })\n\n    // Dispatch background job for embedding\n    const result = await EmbedFileJob.dispatch({\n      filePath: fullPath,\n      fileName,\n    })\n\n    return response.status(202).json({\n      message: result.message,\n      jobId: result.jobId,\n      fileName,\n      filePath: `/${RagService.UPLOADS_STORAGE_PATH}/${fileName}`,\n      alreadyProcessing: !result.created,\n    })\n  }\n\n  public async getActiveJobs({ response }: HttpContext) {\n    const jobs = await EmbedFileJob.listActiveJobs()\n    return response.status(200).json(jobs)\n  }\n\n  public async getJobStatus({ request, response }: HttpContext) {\n    const reqData = await request.validateUsing(getJobStatusSchema)\n\n    const fullPath = app.makePath(RagService.UPLOADS_STORAGE_PATH, reqData.filePath)\n    const status = await EmbedFileJob.getStatus(fullPath)\n\n    if (!status.exists) {\n      return response.status(404).json({ error: 'Job not found for this file' })\n    }\n\n    return response.status(200).json(status)\n  }\n\n  public async getStoredFiles({ response }: HttpContext) {\n    const files = await this.ragService.getStoredFiles()\n    return response.status(200).json({ files })\n  }\n\n  public async deleteFile({ request, response }: HttpContext) {\n    const { source } = await request.validateUsing(deleteFileSchema)\n    const result = await this.ragService.deleteFileBySource(source)\n    if (!result.success) {\n      return response.status(500).json({ error: result.message })\n    }\n    return response.status(200).json({ message: result.message })\n  }\n\n  public async scanAndSync({ response }: HttpContext) {\n    try {\n      const syncResult = await this.ragService.scanAndSyncStorage()\n      return response.status(200).json(syncResult)\n    } catch (error) {\n      return response.status(500).json({ error: 'Error scanning and syncing storage', details: error.message })\n    }\n  }\n}\n"
  },
  {
    "path": "admin/app/controllers/settings_controller.ts",
    "content": "import KVStore from '#models/kv_store';\nimport { BenchmarkService } from '#services/benchmark_service';\nimport { MapService } from '#services/map_service';\nimport { OllamaService } from '#services/ollama_service';\nimport { SystemService } from '#services/system_service';\nimport { updateSettingSchema } from '#validators/settings';\nimport { inject } from '@adonisjs/core';\nimport type { HttpContext } from '@adonisjs/core/http'\nimport type { KVStoreKey } from '../../types/kv_store.js';\n\n@inject()\nexport default class SettingsController {\n    constructor(\n        private systemService: SystemService,\n        private mapService: MapService,\n        private benchmarkService: BenchmarkService,\n        private ollamaService: OllamaService\n    ) { }\n\n    async system({ inertia }: HttpContext) {\n        const systemInfo = await this.systemService.getSystemInfo();\n        return inertia.render('settings/system', {\n            system: {\n                info: systemInfo\n            }\n        });\n    }\n\n    async apps({ inertia }: HttpContext) {\n        const services = await this.systemService.getServices({ installedOnly: false });\n        return inertia.render('settings/apps', {\n            system: {\n                services\n            }\n        });\n    }\n    \n    async legal({ inertia }: HttpContext) {\n        return inertia.render('settings/legal');\n    }\n\n    async support({ inertia }: HttpContext) {\n        return inertia.render('settings/support');\n    }\n\n    async maps({ inertia }: HttpContext) {\n        const baseAssetsCheck = await this.mapService.ensureBaseAssets();\n        const regionFiles = await this.mapService.listRegions();\n        return inertia.render('settings/maps', {\n            maps: {\n                baseAssetsExist: baseAssetsCheck,\n                regionFiles: regionFiles.files\n            }\n        });\n    }\n\n    async models({ inertia }: HttpContext) {\n        const availableModels = await this.ollamaService.getAvailableModels({ sort: 'pulls', recommendedOnly: false, query: null, limit: 15 });\n        const installedModels = await this.ollamaService.getModels();\n        const chatSuggestionsEnabled = await KVStore.getValue('chat.suggestionsEnabled')\n        const aiAssistantCustomName = await KVStore.getValue('ai.assistantCustomName')\n        return inertia.render('settings/models', {\n            models: {\n                availableModels: availableModels?.models || [],\n                installedModels: installedModels || [],\n                settings: {\n                    chatSuggestionsEnabled: chatSuggestionsEnabled ?? false,\n                    aiAssistantCustomName: aiAssistantCustomName ?? '',\n                }\n            }\n        });\n    }\n\n    async update({ inertia }: HttpContext) {\n        const updateInfo = await this.systemService.checkLatestVersion();\n        return inertia.render('settings/update', {\n            system: {\n                updateAvailable: updateInfo.updateAvailable,\n                latestVersion: updateInfo.latestVersion,\n                currentVersion: updateInfo.currentVersion\n            }\n        });\n    }\n\n    async zim({ inertia }: HttpContext) {\n        return inertia.render('settings/zim/index')\n    }\n\n    async zimRemote({ inertia }: HttpContext) {\n        return inertia.render('settings/zim/remote-explorer');\n    }\n\n    async benchmark({ inertia }: HttpContext) {\n        const latestResult = await this.benchmarkService.getLatestResult();\n        const status = this.benchmarkService.getStatus();\n        return inertia.render('settings/benchmark', {\n            benchmark: {\n                latestResult,\n                status: status.status,\n                currentBenchmarkId: status.benchmarkId\n            }\n        });\n    }\n\n    async getSetting({ request, response }: HttpContext) {\n        const key = request.qs().key;\n        const value = await KVStore.getValue(key as KVStoreKey);\n        return response.status(200).send({ key, value });\n    }\n\n    async updateSetting({ request, response }: HttpContext) {\n        const reqData = await request.validateUsing(updateSettingSchema);\n        await this.systemService.updateSetting(reqData.key, reqData.value);\n        return response.status(200).send({ success: true, message: 'Setting updated successfully' });\n    }\n}"
  },
  {
    "path": "admin/app/controllers/system_controller.ts",
    "content": "import { DockerService } from '#services/docker_service';\nimport { SystemService } from '#services/system_service'\nimport { SystemUpdateService } from '#services/system_update_service'\nimport { ContainerRegistryService } from '#services/container_registry_service'\nimport { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job'\nimport { affectServiceValidator, checkLatestVersionValidator, installServiceValidator, subscribeToReleaseNotesValidator, updateServiceValidator } from '#validators/system';\nimport { inject } from '@adonisjs/core'\nimport type { HttpContext } from '@adonisjs/core/http'\n\n@inject()\nexport default class SystemController {\n    constructor(\n        private systemService: SystemService,\n        private dockerService: DockerService,\n        private systemUpdateService: SystemUpdateService,\n        private containerRegistryService: ContainerRegistryService\n    ) { }\n\n    async getInternetStatus({ }: HttpContext) {\n        return await this.systemService.getInternetStatus();\n    }\n\n    async getSystemInfo({ }: HttpContext) {\n        return await this.systemService.getSystemInfo();\n    }\n\n    async getServices({ }: HttpContext) {\n        return await this.systemService.getServices({ installedOnly: true });\n    }\n\n    async installService({ request, response }: HttpContext) {\n        const payload = await request.validateUsing(installServiceValidator);\n\n        const result = await this.dockerService.createContainerPreflight(payload.service_name);\n        if (result.success) {\n            response.send({ success: true, message: result.message });\n        } else {\n            response.status(400).send({ error: result.message });\n        }\n    }\n\n    async affectService({ request, response }: HttpContext) {\n        const payload = await request.validateUsing(affectServiceValidator);\n        const result = await this.dockerService.affectContainer(payload.service_name, payload.action);\n        if (!result) {\n            response.internalServerError({ error: 'Failed to affect service' });\n            return;\n        }\n        response.send({ success: result.success, message: result.message });\n    }\n\n    async checkLatestVersion({ request }: HttpContext) {\n        const payload = await request.validateUsing(checkLatestVersionValidator)\n        return await this.systemService.checkLatestVersion(payload.force);\n    }\n\n    async forceReinstallService({ request, response }: HttpContext) {\n        const payload = await request.validateUsing(installServiceValidator);\n        const result = await this.dockerService.forceReinstall(payload.service_name);\n        if (!result) {\n            response.internalServerError({ error: 'Failed to force reinstall service' });\n            return;\n        }\n        response.send({ success: result.success, message: result.message });\n    }\n\n    async requestSystemUpdate({ response }: HttpContext) {\n        if (!this.systemUpdateService.isSidecarAvailable()) {\n            response.status(503).send({\n                success: false,\n                error: 'Update sidecar is not available. Ensure the updater container is running.',\n            });\n            return;\n        }\n\n        const result = await this.systemUpdateService.requestUpdate();\n\n        if (result.success) {\n            response.send({\n                success: true,\n                message: result.message,\n                note: 'Monitor update progress via GET /api/system/update/status. The connection may drop during container restart.',\n            });\n        } else {\n            response.status(409).send({\n                success: false,\n                error: result.message,\n            });\n        }\n    }\n\n    async getSystemUpdateStatus({ response }: HttpContext) {\n        const status = this.systemUpdateService.getUpdateStatus();\n\n        if (!status) {\n            response.status(500).send({\n                error: 'Failed to retrieve update status',\n            });\n            return;\n        }\n\n        response.send(status);\n    }\n\n    async getSystemUpdateLogs({ response }: HttpContext) {\n        const logs = this.systemUpdateService.getUpdateLogs();\n        response.send({ logs });\n    }\n\n\n    async subscribeToReleaseNotes({ request }: HttpContext) {\n        const reqData = await request.validateUsing(subscribeToReleaseNotesValidator);\n        return await this.systemService.subscribeToReleaseNotes(reqData.email);\n    }\n\n    async getDebugInfo({}: HttpContext) {\n        const debugInfo = await this.systemService.getDebugInfo()\n        return { debugInfo }\n    }\n\n    async checkServiceUpdates({ response }: HttpContext) {\n        await CheckServiceUpdatesJob.dispatch()\n        response.send({ success: true, message: 'Service update check dispatched' })\n    }\n\n    async getAvailableVersions({ params, response }: HttpContext) {\n        const serviceName = params.name\n        const service = await (await import('#models/service')).default\n            .query()\n            .where('service_name', serviceName)\n            .where('installed', true)\n            .first()\n\n        if (!service) {\n            return response.status(404).send({ error: `Service ${serviceName} not found or not installed` })\n        }\n\n        try {\n            const hostArch = await this.getHostArch()\n            const updates = await this.containerRegistryService.getAvailableUpdates(\n                service.container_image,\n                hostArch,\n                service.source_repo\n            )\n            response.send({ versions: updates })\n        } catch (error) {\n            response.status(500).send({ error: `Failed to fetch versions: ${error.message}` })\n        }\n    }\n\n    async updateService({ request, response }: HttpContext) {\n        const payload = await request.validateUsing(updateServiceValidator)\n        const result = await this.dockerService.updateContainer(\n            payload.service_name,\n            payload.target_version\n        )\n\n        if (result.success) {\n            response.send({ success: true, message: result.message })\n        } else {\n            response.status(400).send({ error: result.message })\n        }\n    }\n\n    private async getHostArch(): Promise<string> {\n        try {\n            const info = await this.dockerService.docker.info()\n            const arch = info.Architecture || ''\n            const archMap: Record<string, string> = {\n                x86_64: 'amd64',\n                aarch64: 'arm64',\n                armv7l: 'arm',\n                amd64: 'amd64',\n                arm64: 'arm64',\n            }\n            return archMap[arch] || arch.toLowerCase()\n        } catch {\n            return 'amd64'\n        }\n    }\n}"
  },
  {
    "path": "admin/app/controllers/zim_controller.ts",
    "content": "import { ZimService } from '#services/zim_service'\nimport {\n  assertNotPrivateUrl,\n  downloadCategoryTierValidator,\n  filenameParamValidator,\n  remoteDownloadWithMetadataValidator,\n  selectWikipediaValidator,\n} from '#validators/common'\nimport { listRemoteZimValidator } from '#validators/zim'\nimport { inject } from '@adonisjs/core'\nimport type { HttpContext } from '@adonisjs/core/http'\n\n@inject()\nexport default class ZimController {\n  constructor(private zimService: ZimService) {}\n\n  async list({}: HttpContext) {\n    return await this.zimService.list()\n  }\n\n  async listRemote({ request }: HttpContext) {\n    const payload = await request.validateUsing(listRemoteZimValidator)\n    const { start = 0, count = 12, query } = payload\n    return await this.zimService.listRemote({ start, count, query })\n  }\n\n  async downloadRemote({ request }: HttpContext) {\n    const payload = await request.validateUsing(remoteDownloadWithMetadataValidator)\n    assertNotPrivateUrl(payload.url)\n    const { filename, jobId } = await this.zimService.downloadRemote(payload.url)\n\n    return {\n      message: 'Download started successfully',\n      filename,\n      jobId,\n      url: payload.url,\n    }\n  }\n\n  async listCuratedCategories({}: HttpContext) {\n    return await this.zimService.listCuratedCategories()\n  }\n\n  async downloadCategoryTier({ request }: HttpContext) {\n    const payload = await request.validateUsing(downloadCategoryTierValidator)\n    const resources = await this.zimService.downloadCategoryTier(\n      payload.categorySlug,\n      payload.tierSlug\n    )\n\n    return {\n      message: 'Download started successfully',\n      categorySlug: payload.categorySlug,\n      tierSlug: payload.tierSlug,\n      resources,\n    }\n  }\n\n  async delete({ request, response }: HttpContext) {\n    const payload = await request.validateUsing(filenameParamValidator)\n\n    try {\n      await this.zimService.delete(payload.params.filename)\n    } catch (error) {\n      if (error.message === 'not_found') {\n        return response.status(404).send({\n          message: `ZIM file with key ${payload.params.filename} not found`,\n        })\n      }\n      throw error // Re-throw any other errors and let the global error handler catch\n    }\n\n    return {\n      message: 'ZIM file deleted successfully',\n    }\n  }\n\n  // Wikipedia selector endpoints\n\n  async getWikipediaState({}: HttpContext) {\n    return this.zimService.getWikipediaState()\n  }\n\n  async selectWikipedia({ request }: HttpContext) {\n    const payload = await request.validateUsing(selectWikipediaValidator)\n    return this.zimService.selectWikipedia(payload.optionId)\n  }\n}\n"
  },
  {
    "path": "admin/app/exceptions/handler.ts",
    "content": "import app from '@adonisjs/core/services/app'\nimport { HttpContext, ExceptionHandler } from '@adonisjs/core/http'\nimport type { StatusPageRange, StatusPageRenderer } from '@adonisjs/core/types/http'\n\nexport default class HttpExceptionHandler extends ExceptionHandler {\n  /**\n   * In debug mode, the exception handler will display verbose errors\n   * with pretty printed stack traces.\n   */\n  protected debug = !app.inProduction\n\n  /**\n   * Status pages are used to display a custom HTML pages for certain error\n   * codes. You might want to enable them in production only, but feel\n   * free to enable them in development as well.\n   */\n  protected renderStatusPages = app.inProduction\n\n  /**\n   * Status pages is a collection of error code range and a callback\n   * to return the HTML contents to send as a response.\n   */\n  protected statusPages: Record<StatusPageRange, StatusPageRenderer> = {\n    '404': (error, { inertia }) => inertia.render('errors/not_found', { error }),\n    '500..599': (error, { inertia }) => inertia.render('errors/server_error', { error }),\n  }\n\n  /**\n   * The method is used for handling errors and returning\n   * response to the client\n   */\n  async handle(error: unknown, ctx: HttpContext) {\n    return super.handle(error, ctx)\n  }\n\n  /**\n   * The method is used to report error to the logging service or\n   * the a third party error monitoring service.\n   *\n   * @note You should not attempt to send a response from this method.\n   */\n  async report(error: unknown, ctx: HttpContext) {\n    return super.report(error, ctx)\n  }\n}\n"
  },
  {
    "path": "admin/app/exceptions/internal_server_error_exception.ts",
    "content": "import { Exception } from '@adonisjs/core/exceptions'\n\nexport default class InternalServerErrorException extends Exception {\n  static status = 500\n  static code = 'E_INTERNAL_SERVER_ERROR'\n}"
  },
  {
    "path": "admin/app/jobs/check_service_updates_job.ts",
    "content": "import { Job } from 'bullmq'\nimport { QueueService } from '#services/queue_service'\nimport { DockerService } from '#services/docker_service'\nimport { ContainerRegistryService } from '#services/container_registry_service'\nimport Service from '#models/service'\nimport logger from '@adonisjs/core/services/logger'\nimport transmit from '@adonisjs/transmit/services/main'\nimport { BROADCAST_CHANNELS } from '../../constants/broadcast.js'\nimport { DateTime } from 'luxon'\n\nexport class CheckServiceUpdatesJob {\n  static get queue() {\n    return 'service-updates'\n  }\n\n  static get key() {\n    return 'check-service-updates'\n  }\n\n  async handle(_job: Job) {\n    logger.info('[CheckServiceUpdatesJob] Checking for service updates...')\n\n    const dockerService = new DockerService()\n    const registryService = new ContainerRegistryService()\n\n    // Determine host architecture\n    const hostArch = await this.getHostArch(dockerService)\n\n    const installedServices = await Service.query().where('installed', true)\n    let updatesFound = 0\n\n    for (const service of installedServices) {\n      try {\n        const updates = await registryService.getAvailableUpdates(\n          service.container_image,\n          hostArch,\n          service.source_repo\n        )\n\n        const latestUpdate = updates.length > 0 ? updates[0].tag : null\n\n        service.available_update_version = latestUpdate\n        service.update_checked_at = DateTime.now()\n        await service.save()\n\n        if (latestUpdate) {\n          updatesFound++\n          logger.info(\n            `[CheckServiceUpdatesJob] Update available for ${service.service_name}: ${service.container_image} → ${latestUpdate}`\n          )\n        }\n      } catch (error) {\n        logger.error(\n          `[CheckServiceUpdatesJob] Failed to check updates for ${service.service_name}: ${error.message}`\n        )\n        // Continue checking other services\n      }\n    }\n\n    logger.info(\n      `[CheckServiceUpdatesJob] Completed. ${updatesFound} update(s) found for ${installedServices.length} service(s).`\n    )\n\n    // Broadcast completion so the frontend can refresh\n    transmit.broadcast(BROADCAST_CHANNELS.SERVICE_UPDATES, {\n      status: 'completed',\n      updatesFound,\n      timestamp: new Date().toISOString(),\n    })\n\n    return { updatesFound }\n  }\n\n  private async getHostArch(dockerService: DockerService): Promise<string> {\n    try {\n      const info = await dockerService.docker.info()\n      const arch = info.Architecture || ''\n\n      // Map Docker architecture names to OCI names\n      const archMap: Record<string, string> = {\n        x86_64: 'amd64',\n        aarch64: 'arm64',\n        armv7l: 'arm',\n        amd64: 'amd64',\n        arm64: 'arm64',\n      }\n\n      return archMap[arch] || arch.toLowerCase()\n    } catch (error) {\n      logger.warn(\n        `[CheckServiceUpdatesJob] Could not detect host architecture: ${error.message}. Defaulting to amd64.`\n      )\n      return 'amd64'\n    }\n  }\n\n  static async scheduleNightly() {\n    const queueService = new QueueService()\n    const queue = queueService.getQueue(this.queue)\n\n    await queue.upsertJobScheduler(\n      'nightly-service-update-check',\n      { pattern: '0 3 * * *' },\n      {\n        name: this.key,\n        opts: {\n          removeOnComplete: { count: 7 },\n          removeOnFail: { count: 5 },\n        },\n      }\n    )\n\n    logger.info('[CheckServiceUpdatesJob] Service update check scheduled with cron: 0 3 * * *')\n  }\n\n  static async dispatch() {\n    const queueService = new QueueService()\n    const queue = queueService.getQueue(this.queue)\n\n    const job = await queue.add(\n      this.key,\n      {},\n      {\n        attempts: 3,\n        backoff: { type: 'exponential', delay: 60000 },\n        removeOnComplete: { count: 7 },\n        removeOnFail: { count: 5 },\n      }\n    )\n\n    logger.info(`[CheckServiceUpdatesJob] Dispatched ad-hoc service update check job ${job.id}`)\n    return job\n  }\n}\n"
  },
  {
    "path": "admin/app/jobs/check_update_job.ts",
    "content": "import { Job } from 'bullmq'\nimport { QueueService } from '#services/queue_service'\nimport { DockerService } from '#services/docker_service'\nimport { SystemService } from '#services/system_service'\nimport logger from '@adonisjs/core/services/logger'\nimport KVStore from '#models/kv_store'\n\nexport class CheckUpdateJob {\n  static get queue() {\n    return 'system'\n  }\n\n  static get key() {\n    return 'check-update'\n  }\n\n  async handle(_job: Job) {\n    logger.info('[CheckUpdateJob] Running update check...')\n\n    const dockerService = new DockerService()\n    const systemService = new SystemService(dockerService)\n\n    try {\n      const result = await systemService.checkLatestVersion()\n\n      if (result.updateAvailable) {\n        logger.info(\n          `[CheckUpdateJob] Update available: ${result.currentVersion} → ${result.latestVersion}`\n        )\n      } else {\n        await KVStore.setValue('system.updateAvailable', false)\n        logger.info(\n          `[CheckUpdateJob] System is up to date (${result.currentVersion})`\n        )\n      }\n\n      return result\n    } catch (error) {\n      logger.error(`[CheckUpdateJob] Update check failed: ${error.message}`)\n      throw error\n    }\n  }\n\n  static async scheduleNightly() {\n    const queueService = new QueueService()\n    const queue = queueService.getQueue(this.queue)\n\n    await queue.upsertJobScheduler(\n      'nightly-update-check',\n      { pattern: '0 2,14 * * *' }, // Every 12 hours at 2am and 2pm\n      {\n        name: this.key,\n        opts: {\n          removeOnComplete: { count: 7 },\n          removeOnFail: { count: 5 },\n        },\n      }\n    )\n\n    logger.info('[CheckUpdateJob] Update check scheduled with cron: 0 2,14 * * *')\n  }\n\n  static async dispatch() {\n    const queueService = new QueueService()\n    const queue = queueService.getQueue(this.queue)\n\n    const job = await queue.add(this.key, {}, {\n      attempts: 3,\n      backoff: { type: 'exponential', delay: 60000 },\n      removeOnComplete: { count: 7 },\n      removeOnFail: { count: 5 },\n    })\n\n    logger.info(`[CheckUpdateJob] Dispatched ad-hoc update check job ${job.id}`)\n    return job\n  }\n}\n"
  },
  {
    "path": "admin/app/jobs/download_model_job.ts",
    "content": "import { Job } from 'bullmq'\nimport { QueueService } from '#services/queue_service'\nimport { createHash } from 'crypto'\nimport logger from '@adonisjs/core/services/logger'\nimport { OllamaService } from '#services/ollama_service'\n\nexport interface DownloadModelJobParams {\n  modelName: string\n}\n\nexport class DownloadModelJob {\n  static get queue() {\n    return 'model-downloads'\n  }\n\n  static get key() {\n    return 'download-model'\n  }\n\n  static getJobId(modelName: string): string {\n    return createHash('sha256').update(modelName).digest('hex').slice(0, 16)\n  }\n\n  async handle(job: Job) {\n    const { modelName } = job.data as DownloadModelJobParams\n\n    logger.info(`[DownloadModelJob] Attempting to download model: ${modelName}`)\n\n    const ollamaService = new OllamaService()\n\n    // Even if no models are installed, this should return an empty array if ready\n    const existingModels = await ollamaService.getModels()\n    if (!existingModels) {\n      logger.warn(\n        `[DownloadModelJob] Ollama service not ready yet for model ${modelName}. Will retry...`\n      )\n      throw new Error('Ollama service not ready yet')\n    }\n\n    logger.info(\n      `[DownloadModelJob] Ollama service is ready. Initiating download for ${modelName}`\n    )\n\n    // Services are ready, initiate the download with progress tracking\n    const result = await ollamaService.downloadModel(modelName, (progressPercent) => {\n      if (progressPercent) {\n        job.updateProgress(Math.floor(progressPercent))\n        logger.info(\n          `[DownloadModelJob] Model ${modelName}: ${progressPercent}%`\n        )\n      }\n\n      // Store detailed progress in job data for clients to query\n      job.updateData({\n        ...job.data,\n        status: 'downloading',\n        progress: progressPercent,\n        progress_timestamp: new Date().toISOString(),\n      })\n    })\n\n    if (!result.success) {\n      logger.error(\n        `[DownloadModelJob] Failed to initiate download for model ${modelName}: ${result.message}`\n      )\n      throw new Error(`Failed to initiate download for model: ${result.message}`)\n    }\n\n    logger.info(`[DownloadModelJob] Successfully completed download for model ${modelName}`)\n    return {\n      modelName,\n      message: result.message,\n    }\n  }\n\n  static async getByModelName(modelName: string): Promise<Job | undefined> {\n    const queueService = new QueueService()\n    const queue = queueService.getQueue(this.queue)\n    const jobId = this.getJobId(modelName)\n    return await queue.getJob(jobId)\n  }\n\n  static async dispatch(params: DownloadModelJobParams) {\n    const queueService = new QueueService()\n    const queue = queueService.getQueue(this.queue)\n    const jobId = this.getJobId(params.modelName)\n\n    try {\n      const job = await queue.add(this.key, params, {\n        jobId,\n        attempts: 40, // Many attempts since services may take considerable time to install\n        backoff: {\n          type: 'fixed',\n          delay: 60000, // Check every 60 seconds\n        },\n        removeOnComplete: false, // Keep for status checking\n        removeOnFail: false, // Keep failed jobs for debugging\n      })\n\n      return {\n        job,\n        created: true,\n        message: `Dispatched model download job for ${params.modelName}`,\n      }\n    } catch (error) {\n      if (error.message.includes('job already exists')) {\n        const existing = await queue.getJob(jobId)\n        return {\n          job: existing,\n          created: false,\n          message: `Job already exists for model ${params.modelName}`,\n        }\n      }\n      throw error\n    }\n  }\n}\n"
  },
  {
    "path": "admin/app/jobs/embed_file_job.ts",
    "content": "import { Job, UnrecoverableError } from 'bullmq'\nimport { QueueService } from '#services/queue_service'\nimport { EmbedJobWithProgress } from '../../types/rag.js'\nimport { RagService } from '#services/rag_service'\nimport { DockerService } from '#services/docker_service'\nimport { OllamaService } from '#services/ollama_service'\nimport { createHash } from 'crypto'\nimport logger from '@adonisjs/core/services/logger'\n\nexport interface EmbedFileJobParams {\n  filePath: string\n  fileName: string\n  fileSize?: number\n  // Batch processing for large ZIM files\n  batchOffset?: number  // Current batch offset (for ZIM files)\n  totalArticles?: number // Total articles in ZIM (for progress tracking)\n  isFinalBatch?: boolean // Whether this is the last batch (prevents premature deletion)\n}\n\nexport class EmbedFileJob {\n  static get queue() {\n    return 'file-embeddings'\n  }\n\n  static get key() {\n    return 'embed-file'\n  }\n\n  static getJobId(filePath: string): string {\n    return createHash('sha256').update(filePath).digest('hex').slice(0, 16)\n  }\n\n  async handle(job: Job) {\n    const { filePath, fileName, batchOffset, totalArticles } = job.data as EmbedFileJobParams\n\n    const isZimBatch = batchOffset !== undefined\n    const batchInfo = isZimBatch ? ` (batch offset: ${batchOffset})` : ''\n    logger.info(`[EmbedFileJob] Starting embedding process for: ${fileName}${batchInfo}`)\n\n    const dockerService = new DockerService()\n    const ollamaService = new OllamaService()\n    const ragService = new RagService(dockerService, ollamaService)\n\n    try {\n      // Check if Ollama and Qdrant services are installed and ready\n      // Use UnrecoverableError for \"not installed\" so BullMQ won't retry —\n      // retrying 30x when the service doesn't exist just wastes Redis connections\n      const ollamaUrl = await dockerService.getServiceURL('nomad_ollama')\n      if (!ollamaUrl) {\n        logger.warn('[EmbedFileJob] Ollama is not installed. Skipping embedding for: %s', fileName)\n        throw new UnrecoverableError('Ollama service is not installed. Install AI Assistant to enable file embeddings.')\n      }\n\n      const existingModels = await ollamaService.getModels()\n      if (!existingModels) {\n        logger.warn('[EmbedFileJob] Ollama service not ready yet. Will retry...')\n        throw new Error('Ollama service not ready yet')\n      }\n\n      const qdrantUrl = await dockerService.getServiceURL('nomad_qdrant')\n      if (!qdrantUrl) {\n        logger.warn('[EmbedFileJob] Qdrant is not installed. Skipping embedding for: %s', fileName)\n        throw new UnrecoverableError('Qdrant service is not installed. Install AI Assistant to enable file embeddings.')\n      }\n\n      logger.info(`[EmbedFileJob] Services ready. Processing file: ${fileName}`)\n\n      // Update progress starting\n      await job.updateProgress(5)\n      await job.updateData({\n        ...job.data,\n        status: 'processing',\n        startedAt: job.data.startedAt || Date.now(),\n      })\n\n      logger.info(`[EmbedFileJob] Processing file: ${filePath}`)\n\n      // Progress callback: maps service-reported 0-100% into the 5-95% job range\n      const onProgress = async (percent: number) => {\n        await job.updateProgress(Math.min(95, Math.round(5 + percent * 0.9)))\n      }\n\n      // Process and embed the file\n      // Only allow deletion if explicitly marked as final batch\n      const allowDeletion = job.data.isFinalBatch === true\n      const result = await ragService.processAndEmbedFile(\n        filePath,\n        allowDeletion,\n        batchOffset,\n        onProgress\n      )\n\n      if (!result.success) {\n        logger.error(`[EmbedFileJob] Failed to process file ${fileName}: ${result.message}`)\n        throw new Error(result.message)\n      }\n\n      // For ZIM files with batching, check if more batches are needed\n      if (result.hasMoreBatches) {\n        const nextOffset = (batchOffset || 0) + (result.articlesProcessed || 0)\n        logger.info(\n          `[EmbedFileJob] Batch complete. Dispatching next batch at offset ${nextOffset}`\n        )\n\n        // Dispatch next batch (not final yet)\n        await EmbedFileJob.dispatch({\n          filePath,\n          fileName,\n          batchOffset: nextOffset,\n          totalArticles: totalArticles || result.totalArticles,\n          isFinalBatch: false, // Explicitly not final\n        })\n\n        // Calculate progress based on articles processed\n        const progress = totalArticles\n          ? Math.round((nextOffset / totalArticles) * 100)\n          : 50\n\n        await job.updateProgress(progress)\n        await job.updateData({\n          ...job.data,\n          status: 'batch_completed',\n          lastBatchAt: Date.now(),\n          chunks: (job.data.chunks || 0) + (result.chunks || 0),\n        })\n\n        return {\n          success: true,\n          fileName,\n          filePath,\n          chunks: result.chunks,\n          hasMoreBatches: true,\n          nextOffset,\n          message: `Batch embedded ${result.chunks} chunks, next batch queued`,\n        }\n      }\n\n      // Final batch or non-batched file - mark as complete\n      const totalChunks = (job.data.chunks || 0) + (result.chunks || 0)\n      await job.updateProgress(100)\n      await job.updateData({\n        ...job.data,\n        status: 'completed',\n        completedAt: Date.now(),\n        chunks: totalChunks,\n      })\n\n      const batchMsg = isZimBatch ? ` (final batch, total chunks: ${totalChunks})` : ''\n      logger.info(\n        `[EmbedFileJob] Successfully embedded ${result.chunks} chunks from file: ${fileName}${batchMsg}`\n      )\n\n      return {\n        success: true,\n        fileName,\n        filePath,\n        chunks: result.chunks,\n        message: `Successfully embedded ${result.chunks} chunks`,\n      }\n    } catch (error) {\n      logger.error(`[EmbedFileJob] Error embedding file ${fileName}:`, error)\n\n      await job.updateData({\n        ...job.data,\n        status: 'failed',\n        failedAt: Date.now(),\n        error: error instanceof Error ? error.message : 'Unknown error',\n      })\n\n      throw error\n    }\n  }\n\n  static async listActiveJobs(): Promise<EmbedJobWithProgress[]> {\n    const queueService = new QueueService()\n    const queue = queueService.getQueue(this.queue)\n    const jobs = await queue.getJobs(['waiting', 'active', 'delayed'])\n\n    return jobs.map((job) => ({\n      jobId: job.id!.toString(),\n      fileName: (job.data as EmbedFileJobParams).fileName,\n      filePath: (job.data as EmbedFileJobParams).filePath,\n      progress: typeof job.progress === 'number' ? job.progress : 0,\n      status: ((job.data as any).status as string) ?? 'waiting',\n    }))\n  }\n\n  static async getByFilePath(filePath: string): Promise<Job | undefined> {\n    const queueService = new QueueService()\n    const queue = queueService.getQueue(this.queue)\n    const jobId = this.getJobId(filePath)\n    return await queue.getJob(jobId)\n  }\n\n  static async dispatch(params: EmbedFileJobParams) {\n    const queueService = new QueueService()\n    const queue = queueService.getQueue(this.queue)\n    const jobId = this.getJobId(params.filePath)\n\n    try {\n      const job = await queue.add(this.key, params, {\n        jobId,\n        attempts: 30,\n        backoff: {\n          type: 'fixed',\n          delay: 60000, // Check every 60 seconds for service readiness\n        },\n        removeOnComplete: { count: 50 }, // Keep last 50 completed jobs for history\n        removeOnFail: { count: 20 } // Keep last 20 failed jobs for debugging\n      })\n\n      logger.info(`[EmbedFileJob] Dispatched embedding job for file: ${params.fileName}`)\n\n      return {\n        job,\n        created: true,\n        jobId,\n        message: `File queued for embedding: ${params.fileName}`,\n      }\n    } catch (error) {\n      if (error.message && error.message.includes('job already exists')) {\n        const existing = await queue.getJob(jobId)\n        logger.info(`[EmbedFileJob] Job already exists for file: ${params.fileName}`)\n        return {\n          job: existing,\n          created: false,\n          jobId,\n          message: `Embedding job already exists for: ${params.fileName}`,\n        }\n      }\n      throw error\n    }\n  }\n\n  static async getStatus(filePath: string): Promise<{\n    exists: boolean\n    status?: string\n    progress?: number\n    chunks?: number\n    error?: string\n  }> {\n    const job = await this.getByFilePath(filePath)\n\n    if (!job) {\n      return { exists: false }\n    }\n\n    const state = await job.getState()\n    const data = job.data\n\n    return {\n      exists: true,\n      status: data.status || state,\n      progress: typeof job.progress === 'number' ? job.progress : undefined,\n      chunks: data.chunks,\n      error: data.error,\n    }\n  }\n}\n"
  },
  {
    "path": "admin/app/jobs/run_benchmark_job.ts",
    "content": "import { Job } from 'bullmq'\nimport { QueueService } from '#services/queue_service'\nimport { BenchmarkService } from '#services/benchmark_service'\nimport type { RunBenchmarkJobParams } from '../../types/benchmark.js'\nimport logger from '@adonisjs/core/services/logger'\nimport { DockerService } from '#services/docker_service'\n\nexport class RunBenchmarkJob {\n  static get queue() {\n    return 'benchmarks'\n  }\n\n  static get key() {\n    return 'run-benchmark'\n  }\n\n  async handle(job: Job) {\n    const { benchmark_id, benchmark_type } = job.data as RunBenchmarkJobParams\n\n    logger.info(`[RunBenchmarkJob] Starting benchmark ${benchmark_id} of type ${benchmark_type}`)\n\n    const dockerService = new DockerService()\n    const benchmarkService = new BenchmarkService(dockerService)\n\n    try {\n      let result\n\n      switch (benchmark_type) {\n        case 'full':\n          result = await benchmarkService.runFullBenchmark()\n          break\n        case 'system':\n          result = await benchmarkService.runSystemBenchmarks()\n          break\n        case 'ai':\n          result = await benchmarkService.runAIBenchmark()\n          break\n        default:\n          throw new Error(`Unknown benchmark type: ${benchmark_type}`)\n      }\n\n      logger.info(`[RunBenchmarkJob] Benchmark ${benchmark_id} completed with NOMAD score: ${result.nomad_score}`)\n\n      return {\n        success: true,\n        benchmark_id: result.benchmark_id,\n        nomad_score: result.nomad_score,\n      }\n    } catch (error) {\n      logger.error(`[RunBenchmarkJob] Benchmark ${benchmark_id} failed: ${error.message}`)\n      throw error\n    }\n  }\n\n  static async dispatch(params: RunBenchmarkJobParams) {\n    const queueService = new QueueService()\n    const queue = queueService.getQueue(this.queue)\n\n    try {\n      const job = await queue.add(this.key, params, {\n        jobId: params.benchmark_id,\n        attempts: 1, // Benchmarks shouldn't be retried automatically\n        removeOnComplete: {\n          count: 10, // Keep last 10 completed jobs\n        },\n        removeOnFail: {\n          count: 5, // Keep last 5 failed jobs\n        },\n      })\n\n      logger.info(`[RunBenchmarkJob] Dispatched benchmark job ${params.benchmark_id}`)\n\n      return {\n        job,\n        created: true,\n        message: `Benchmark job ${params.benchmark_id} dispatched successfully`,\n      }\n    } catch (error) {\n      if (error.message.includes('job already exists')) {\n        const existing = await queue.getJob(params.benchmark_id)\n        return {\n          job: existing,\n          created: false,\n          message: `Benchmark job ${params.benchmark_id} already exists`,\n        }\n      }\n      throw error\n    }\n  }\n\n  static async getJob(benchmarkId: string): Promise<Job | undefined> {\n    const queueService = new QueueService()\n    const queue = queueService.getQueue(this.queue)\n    return await queue.getJob(benchmarkId)\n  }\n\n  static async getJobState(benchmarkId: string): Promise<string | undefined> {\n    const job = await this.getJob(benchmarkId)\n    return job ? await job.getState() : undefined\n  }\n}\n"
  },
  {
    "path": "admin/app/jobs/run_download_job.ts",
    "content": "import { Job } from 'bullmq'\nimport { RunDownloadJobParams } from '../../types/downloads.js'\nimport { QueueService } from '#services/queue_service'\nimport { doResumableDownload } from '../utils/downloads.js'\nimport { createHash } from 'crypto'\nimport { DockerService } from '#services/docker_service'\nimport { ZimService } from '#services/zim_service'\nimport { MapService } from '#services/map_service'\nimport { EmbedFileJob } from './embed_file_job.js'\n\nexport class RunDownloadJob {\n  static get queue() {\n    return 'downloads'\n  }\n\n  static get key() {\n    return 'run-download'\n  }\n\n  static getJobId(url: string): string {\n    return createHash('sha256').update(url).digest('hex').slice(0, 16)\n  }\n\n  async handle(job: Job) {\n    const { url, filepath, timeout, allowedMimeTypes, forceNew, filetype, resourceMetadata } =\n      job.data as RunDownloadJobParams\n\n    await doResumableDownload({\n      url,\n      filepath,\n      timeout,\n      allowedMimeTypes,\n      forceNew,\n      onProgress(progress) {\n        const progressPercent = (progress.downloadedBytes / (progress.totalBytes || 1)) * 100\n        job.updateProgress(Math.floor(progressPercent))\n      },\n      async onComplete(url) {\n        try {\n          // Create InstalledResource entry if metadata was provided\n          if (resourceMetadata) {\n            const { default: InstalledResource } = await import('#models/installed_resource')\n            const { DateTime } = await import('luxon')\n            const { getFileStatsIfExists, deleteFileIfExists } = await import('../utils/fs.js')\n            const stats = await getFileStatsIfExists(filepath)\n\n            // Look up the old entry so we can clean up the previous file after updating\n            const oldEntry = await InstalledResource.query()\n              .where('resource_id', resourceMetadata.resource_id)\n              .where('resource_type', filetype as 'zim' | 'map')\n              .first()\n            const oldFilePath = oldEntry?.file_path ?? null\n\n            await InstalledResource.updateOrCreate(\n              { resource_id: resourceMetadata.resource_id, resource_type: filetype as 'zim' | 'map' },\n              {\n                version: resourceMetadata.version,\n                collection_ref: resourceMetadata.collection_ref,\n                url: url,\n                file_path: filepath,\n                file_size_bytes: stats ? Number(stats.size) : null,\n                installed_at: DateTime.now(),\n              }\n            )\n\n            // Delete the old file if it differs from the new one\n            if (oldFilePath && oldFilePath !== filepath) {\n              try {\n                await deleteFileIfExists(oldFilePath)\n                console.log(`[RunDownloadJob] Deleted old file: ${oldFilePath}`)\n              } catch (deleteError) {\n                console.warn(\n                  `[RunDownloadJob] Failed to delete old file ${oldFilePath}:`,\n                  deleteError\n                )\n              }\n            }\n          }\n\n          if (filetype === 'zim') {\n            const dockerService = new DockerService()\n            const zimService = new ZimService(dockerService)\n            await zimService.downloadRemoteSuccessCallback([url], true)\n\n            // Only dispatch embedding job if AI Assistant (Ollama) is installed\n            const ollamaUrl = await dockerService.getServiceURL('nomad_ollama')\n            if (ollamaUrl) {\n              try {\n                await EmbedFileJob.dispatch({\n                  fileName: url.split('/').pop() || '',\n                  filePath: filepath,\n                })\n              } catch (error) {\n                console.error(`[RunDownloadJob] Error dispatching EmbedFileJob for URL ${url}:`, error)\n              }\n            }\n          } else if (filetype === 'map') {\n            const mapsService = new MapService()\n            await mapsService.downloadRemoteSuccessCallback([url], false)\n          }\n        } catch (error) {\n          console.error(\n            `[RunDownloadJob] Error in download success callback for URL ${url}:`,\n            error\n          )\n        }\n        job.updateProgress(100)\n      },\n    })\n\n    return {\n      url,\n      filepath,\n    }\n  }\n\n  static async getByUrl(url: string): Promise<Job | undefined> {\n    const queueService = new QueueService()\n    const queue = queueService.getQueue(this.queue)\n    const jobId = this.getJobId(url)\n    return await queue.getJob(jobId)\n  }\n\n  static async dispatch(params: RunDownloadJobParams) {\n    const queueService = new QueueService()\n    const queue = queueService.getQueue(this.queue)\n    const jobId = this.getJobId(params.url)\n\n    try {\n      const job = await queue.add(this.key, params, {\n        jobId,\n        attempts: 3,\n        backoff: { type: 'exponential', delay: 2000 },\n        removeOnComplete: true,\n      })\n\n      return {\n        job,\n        created: true,\n        message: `Dispatched download job for URL ${params.url}`,\n      }\n    } catch (error) {\n      if (error.message.includes('job already exists')) {\n        const existing = await queue.getJob(jobId)\n        return {\n          job: existing,\n          created: false,\n          message: `Job already exists for URL ${params.url}`,\n        }\n      }\n      throw error\n    }\n  }\n}\n"
  },
  {
    "path": "admin/app/middleware/container_bindings_middleware.ts",
    "content": "import { Logger } from '@adonisjs/core/logger'\nimport { HttpContext } from '@adonisjs/core/http'\nimport { NextFn } from '@adonisjs/core/types/http'\n\n/**\n * The container bindings middleware binds classes to their request\n * specific value using the container resolver.\n *\n * - We bind \"HttpContext\" class to the \"ctx\" object\n * - And bind \"Logger\" class to the \"ctx.logger\" object\n */\nexport default class ContainerBindingsMiddleware {\n  handle(ctx: HttpContext, next: NextFn) {\n    ctx.containerResolver.bindValue(HttpContext, ctx)\n    ctx.containerResolver.bindValue(Logger, ctx.logger)\n\n    return next()\n  }\n}\n"
  },
  {
    "path": "admin/app/middleware/force_json_response_middleware.ts",
    "content": "import type { HttpContext } from '@adonisjs/core/http'\nimport type { NextFn } from '@adonisjs/core/types/http'\n\n/**\n * Updating the \"Accept\" header to always accept \"application/json\" response\n * from the server. This will force the internals of the framework like\n * validator errors or auth errors to return a JSON response.\n */\nexport default class ForceJsonResponseMiddleware {\n  async handle({ request }: HttpContext, next: NextFn) {\n    const headers = request.headers()\n    headers.accept = 'application/json'\n\n    return next()\n  }\n}\n"
  },
  {
    "path": "admin/app/middleware/maps_static_middleware.ts",
    "content": "import type { HttpContext } from '@adonisjs/core/http'\nimport type { NextFn } from '@adonisjs/core/types/http'\nimport StaticMiddleware from '@adonisjs/static/static_middleware'\nimport { AssetsConfig } from '@adonisjs/static/types'\n\n/**\n * See #providers/map_static_provider.ts for explanation\n * of why this middleware exists.\n */\nexport default class MapsStaticMiddleware {\n  constructor(\n    private path: string,\n    private config: AssetsConfig\n  ) {}\n\n  async handle(ctx: HttpContext, next: NextFn) {\n    const staticMiddleware = new StaticMiddleware(this.path, this.config)\n    return staticMiddleware.handle(ctx, next)\n  }\n}\n"
  },
  {
    "path": "admin/app/models/benchmark_result.ts",
    "content": "import { DateTime } from 'luxon'\nimport { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'\nimport type { BenchmarkType, DiskType } from '../../types/benchmark.js'\n\nexport default class BenchmarkResult extends BaseModel {\n  static namingStrategy = new SnakeCaseNamingStrategy()\n\n  @column({ isPrimary: true })\n  declare id: number\n\n  @column()\n  declare benchmark_id: string\n\n  @column()\n  declare benchmark_type: BenchmarkType\n\n  // Hardware information\n  @column()\n  declare cpu_model: string\n\n  @column()\n  declare cpu_cores: number\n\n  @column()\n  declare cpu_threads: number\n\n  @column()\n  declare ram_bytes: number\n\n  @column()\n  declare disk_type: DiskType\n\n  @column()\n  declare gpu_model: string | null\n\n  // System benchmark scores\n  @column()\n  declare cpu_score: number\n\n  @column()\n  declare memory_score: number\n\n  @column()\n  declare disk_read_score: number\n\n  @column()\n  declare disk_write_score: number\n\n  // AI benchmark scores (nullable for system-only benchmarks)\n  @column()\n  declare ai_tokens_per_second: number | null\n\n  @column()\n  declare ai_model_used: string | null\n\n  @column()\n  declare ai_time_to_first_token: number | null\n\n  // Composite NOMAD score (0-100)\n  @column()\n  declare nomad_score: number\n\n  // Repository submission tracking\n  @column({\n    serialize(value) {\n      return Boolean(value)\n    },\n  })\n  declare submitted_to_repository: boolean\n\n  @column.dateTime()\n  declare submitted_at: DateTime | null\n\n  @column()\n  declare repository_id: string | null\n\n  @column()\n  declare builder_tag: string | null\n\n  @column.dateTime({ autoCreate: true })\n  declare created_at: DateTime\n\n  @column.dateTime({ autoCreate: true, autoUpdate: true })\n  declare updated_at: DateTime\n}\n"
  },
  {
    "path": "admin/app/models/benchmark_setting.ts",
    "content": "import { DateTime } from 'luxon'\nimport { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'\nimport type { BenchmarkSettingKey } from '../../types/benchmark.js'\n\nexport default class BenchmarkSetting extends BaseModel {\n  static namingStrategy = new SnakeCaseNamingStrategy()\n\n  @column({ isPrimary: true })\n  declare id: number\n\n  @column()\n  declare key: BenchmarkSettingKey\n\n  @column()\n  declare value: string | null\n\n  @column.dateTime({ autoCreate: true })\n  declare created_at: DateTime\n\n  @column.dateTime({ autoCreate: true, autoUpdate: true })\n  declare updated_at: DateTime\n\n  /**\n   * Get a setting value by key\n   */\n  static async getValue(key: BenchmarkSettingKey): Promise<string | null> {\n    const setting = await this.findBy('key', key)\n    return setting?.value ?? null\n  }\n\n  /**\n   * Set a setting value by key (creates if not exists)\n   */\n  static async setValue(key: BenchmarkSettingKey, value: string | null): Promise<BenchmarkSetting> {\n    const setting = await this.firstOrCreate({ key }, { key, value })\n    if (setting.value !== value) {\n      setting.value = value\n      await setting.save()\n    }\n    return setting\n  }\n\n  /**\n   * Get all benchmark settings as a typed object\n   */\n  static async getAllSettings(): Promise<{\n    allow_anonymous_submission: boolean\n    installation_id: string | null\n    last_benchmark_run: string | null\n  }> {\n    const settings = await this.all()\n    const map = new Map(settings.map((s) => [s.key, s.value]))\n\n    return {\n      allow_anonymous_submission: map.get('allow_anonymous_submission') === 'true',\n      installation_id: map.get('installation_id') ?? null,\n      last_benchmark_run: map.get('last_benchmark_run') ?? null,\n    }\n  }\n}\n"
  },
  {
    "path": "admin/app/models/chat_message.ts",
    "content": "import { DateTime } from 'luxon'\nimport { BaseModel, column, belongsTo, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'\nimport type { BelongsTo } from '@adonisjs/lucid/types/relations'\nimport ChatSession from './chat_session.js'\n\nexport default class ChatMessage extends BaseModel {\n  static namingStrategy = new SnakeCaseNamingStrategy()\n\n  @column({ isPrimary: true })\n  declare id: number\n\n  @column()\n  declare session_id: number\n\n  @column()\n  declare role: 'system' | 'user' | 'assistant'\n\n  @column()\n  declare content: string\n\n  @belongsTo(() => ChatSession, { foreignKey: 'id', localKey: 'session_id' })\n  declare session: BelongsTo<typeof ChatSession>\n\n  @column.dateTime({ autoCreate: true })\n  declare created_at: DateTime\n\n  @column.dateTime({ autoCreate: true, autoUpdate: true })\n  declare updated_at: DateTime\n}\n"
  },
  {
    "path": "admin/app/models/chat_session.ts",
    "content": "import { DateTime } from 'luxon'\nimport { BaseModel, column, hasMany, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'\nimport type { HasMany } from '@adonisjs/lucid/types/relations'\nimport ChatMessage from './chat_message.js'\n\nexport default class ChatSession extends BaseModel {\n  static namingStrategy = new SnakeCaseNamingStrategy()\n\n  @column({ isPrimary: true })\n  declare id: number\n\n  @column()\n  declare title: string\n\n  @column()\n  declare model: string | null\n\n  @hasMany(() => ChatMessage, {\n    foreignKey: 'session_id',\n    localKey: 'id',\n  })\n  declare messages: HasMany<typeof ChatMessage>\n\n  @column.dateTime({ autoCreate: true })\n  declare created_at: DateTime\n\n  @column.dateTime({ autoCreate: true, autoUpdate: true })\n  declare updated_at: DateTime\n}"
  },
  {
    "path": "admin/app/models/collection_manifest.ts",
    "content": "import { DateTime } from 'luxon'\nimport { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'\nimport type { ManifestType } from '../../types/collections.js'\n\nexport default class CollectionManifest extends BaseModel {\n  static namingStrategy = new SnakeCaseNamingStrategy()\n\n  @column({ isPrimary: true })\n  declare type: ManifestType\n\n  @column()\n  declare spec_version: string\n\n  @column({\n    consume: (value: string) => (typeof value === 'string' ? JSON.parse(value) : value),\n    prepare: (value: any) => JSON.stringify(value),\n  })\n  declare spec_data: any\n\n  @column.dateTime()\n  declare fetched_at: DateTime\n}\n"
  },
  {
    "path": "admin/app/models/installed_resource.ts",
    "content": "import { DateTime } from 'luxon'\nimport { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'\n\nexport default class InstalledResource extends BaseModel {\n  static namingStrategy = new SnakeCaseNamingStrategy()\n\n  @column({ isPrimary: true })\n  declare id: number\n\n  @column()\n  declare resource_id: string\n\n  @column()\n  declare resource_type: 'zim' | 'map'\n\n  @column()\n  declare collection_ref: string | null\n\n  @column()\n  declare version: string\n\n  @column()\n  declare url: string\n\n  @column()\n  declare file_path: string\n\n  @column()\n  declare file_size_bytes: number | null\n\n  @column.dateTime()\n  declare installed_at: DateTime\n}\n"
  },
  {
    "path": "admin/app/models/kv_store.ts",
    "content": "import { DateTime } from 'luxon'\nimport { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'\nimport { KV_STORE_SCHEMA, type KVStoreKey, type KVStoreValue } from '../../types/kv_store.js'\nimport { parseBoolean } from '../utils/misc.js'\n\n/**\n * Generic key-value store model for storing various settings\n * that don't necessitate their own dedicated models.\n */\nexport default class KVStore extends BaseModel {\n  static table = 'kv_store'\n  static namingStrategy = new SnakeCaseNamingStrategy()\n\n  @column({ isPrimary: true })\n  declare id: number\n\n  @column()\n  declare key: KVStoreKey\n\n  @column()\n  declare value: string | null\n\n  @column.dateTime({ autoCreate: true })\n  declare created_at: DateTime\n\n  @column.dateTime({ autoCreate: true, autoUpdate: true })\n  declare updated_at: DateTime\n\n  /**\n   * Get a setting value by key, automatically deserializing to the correct type.\n   */\n  static async getValue<K extends KVStoreKey>(key: K): Promise<KVStoreValue<K> | null> {\n    const setting = await this.findBy('key', key)\n    if (!setting || setting.value === undefined || setting.value === null) {\n      return null\n    }\n    const raw = String(setting.value)\n    return (KV_STORE_SCHEMA[key] === 'boolean' ? parseBoolean(raw) : raw) as KVStoreValue<K>\n  }\n\n  /**\n   * Set a setting value by key (creates if not exists), automatically serializing to string.\n   */\n  static async setValue<K extends KVStoreKey>(key: K, value: KVStoreValue<K>): Promise<KVStore> {\n    const serialized = String(value)\n    const setting = await this.firstOrCreate({ key }, { key, value: serialized })\n    if (setting.value !== serialized) {\n      setting.value = serialized\n      await setting.save()\n    }\n    return setting\n  }\n\n  /**\n   * Clear a setting value by key, storing null so getValue returns null.\n   */\n  static async clearValue<K extends KVStoreKey>(key: K): Promise<void> {\n    const setting = await this.findBy('key', key)\n    if (setting && setting.value !== null) {\n      setting.value = null\n      await setting.save()\n    }\n  }\n}\n"
  },
  {
    "path": "admin/app/models/service.ts",
    "content": "import { BaseModel, belongsTo, column, hasMany, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'\nimport type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'\nimport { DateTime } from 'luxon'\n\nexport default class Service extends BaseModel {\n  static namingStrategy = new SnakeCaseNamingStrategy()\n\n  @column({ isPrimary: true })\n  declare id: number\n\n  @column()\n  declare service_name: string\n\n  @column()\n  declare container_image: string\n\n  @column()\n  declare container_command: string | null\n\n  @column()\n  declare container_config: string | null\n\n  @column()\n  declare friendly_name: string | null\n\n  @column()\n  declare description: string | null\n\n  @column()\n  declare powered_by: string | null\n\n  @column()\n  declare display_order: number | null\n\n  @column()\n  declare icon: string | null // must be a TablerIcons name to be properly rendered in the UI (e.g. \"IconBrandDocker\")\n\n  @column({\n    serialize(value) {\n      return Boolean(value)\n    },\n  })\n  declare installed: boolean\n\n  @column()\n  declare installation_status: 'idle' | 'installing' | 'error'\n\n  @column()\n  declare depends_on: string | null\n\n  // For services that are dependencies for other services - not intended to be installed directly by users\n  @column({\n    serialize(value) {\n      return Boolean(value)\n    },\n  })\n  declare is_dependency_service: boolean\n\n  @column()\n  declare ui_location: string | null\n\n  @column()\n  declare metadata: string | null\n\n  @column()\n  declare source_repo: string | null\n\n  @column()\n  declare available_update_version: string | null\n\n  @column.dateTime()\n  declare update_checked_at: DateTime | null\n\n  @column.dateTime({ autoCreate: true })\n  declare created_at: DateTime\n\n  @column.dateTime({ autoCreate: true, autoUpdate: true })\n  declare updated_at: DateTime | null\n\n  // Define a self-referential relationship for dependencies\n  @belongsTo(() => Service, {\n    foreignKey: 'depends_on',\n  })\n  declare dependency: BelongsTo<typeof Service>\n\n  @hasMany(() => Service, {\n    foreignKey: 'depends_on',\n  })\n  declare dependencies: HasMany<typeof Service>\n}\n"
  },
  {
    "path": "admin/app/models/wikipedia_selection.ts",
    "content": "import { DateTime } from 'luxon'\nimport { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'\n\nexport default class WikipediaSelection extends BaseModel {\n  static namingStrategy = new SnakeCaseNamingStrategy()\n\n  @column({ isPrimary: true })\n  declare id: number\n\n  @column()\n  declare option_id: string\n\n  @column()\n  declare url: string | null\n\n  @column()\n  declare filename: string | null\n\n  @column()\n  declare status: 'none' | 'downloading' | 'installed' | 'failed'\n\n  @column.dateTime({ autoCreate: true })\n  declare created_at: DateTime\n\n  @column.dateTime({ autoCreate: true, autoUpdate: true })\n  declare updated_at: DateTime\n}\n"
  },
  {
    "path": "admin/app/services/benchmark_service.ts",
    "content": "import { inject } from '@adonisjs/core'\nimport logger from '@adonisjs/core/services/logger'\nimport transmit from '@adonisjs/transmit/services/main'\nimport si from 'systeminformation'\nimport axios from 'axios'\nimport { DateTime } from 'luxon'\nimport BenchmarkResult from '#models/benchmark_result'\nimport BenchmarkSetting from '#models/benchmark_setting'\nimport { SystemService } from '#services/system_service'\nimport type {\n  BenchmarkType,\n  BenchmarkStatus,\n  BenchmarkProgress,\n  HardwareInfo,\n  DiskType,\n  SystemScores,\n  AIScores,\n  SysbenchCpuResult,\n  SysbenchMemoryResult,\n  SysbenchDiskResult,\n  RepositorySubmission,\n  RepositorySubmitResponse,\n  RepositoryStats,\n} from '../../types/benchmark.js'\nimport { randomUUID, createHmac } from 'node:crypto'\nimport { DockerService } from './docker_service.js'\nimport { SERVICE_NAMES } from '../../constants/service_names.js'\nimport { BROADCAST_CHANNELS } from '../../constants/broadcast.js'\nimport Dockerode from 'dockerode'\n\n// HMAC secret for signing submissions to the benchmark repository\n// This provides basic protection against casual API abuse.\n// Note: Since NOMAD is open source, a determined attacker could extract this.\n// For stronger protection, see challenge-response authentication.\nconst BENCHMARK_HMAC_SECRET = '778ba65d0bc0e23119e5ffce4b3716648a7d071f0a47ec3f'\n\n// Re-export default weights for use in service\nconst SCORE_WEIGHTS = {\n  ai_tokens_per_second: 0.30,\n  cpu: 0.25,\n  memory: 0.15,\n  ai_ttft: 0.10,\n  disk_read: 0.10,\n  disk_write: 0.10,\n}\n\n// Benchmark configuration constants\nconst SYSBENCH_IMAGE = 'severalnines/sysbench:latest'\nconst SYSBENCH_CONTAINER_NAME = 'nomad_benchmark_sysbench'\n\n// Reference model for AI benchmark - small but meaningful\nconst AI_BENCHMARK_MODEL = 'llama3.2:1b'\nconst AI_BENCHMARK_PROMPT = 'Explain recursion in programming in exactly 100 words.'\n\n// Reference scores for normalization (calibrated to 0-100 scale)\n// These represent \"expected\" scores for a mid-range system (score ~50)\nconst REFERENCE_SCORES = {\n  cpu_events_per_second: 5000, // sysbench cpu events/sec for ~50 score\n  memory_ops_per_second: 5000000, // sysbench memory ops/sec for ~50 score\n  disk_read_mb_per_sec: 500, // 500 MB/s read for ~50 score\n  disk_write_mb_per_sec: 400, // 400 MB/s write for ~50 score\n  ai_tokens_per_second: 30, // 30 tok/s for ~50 score\n  ai_ttft_ms: 500, // 500ms time to first token for ~50 score (lower is better)\n}\n\n@inject()\nexport class BenchmarkService {\n  private currentBenchmarkId: string | null = null\n  private currentStatus: BenchmarkStatus = 'idle'\n\n  constructor(private dockerService: DockerService) {}\n\n  /**\n   * Run a full benchmark suite\n   */\n  async runFullBenchmark(): Promise<BenchmarkResult> {\n    return this._runBenchmark('full', true)\n  }\n\n  /**\n   * Run system benchmarks only (CPU, memory, disk)\n   */\n  async runSystemBenchmarks(): Promise<BenchmarkResult> {\n    return this._runBenchmark('system', false)\n  }\n\n  /**\n   * Run AI benchmark only\n   */\n  async runAIBenchmark(): Promise<BenchmarkResult> {\n    return this._runBenchmark('ai', true)\n  }\n\n  /**\n   * Get the latest benchmark result\n   */\n  async getLatestResult(): Promise<BenchmarkResult | null> {\n    return await BenchmarkResult.query().orderBy('created_at', 'desc').first()\n  }\n\n  /**\n   * Get all benchmark results\n   */\n  async getAllResults(): Promise<BenchmarkResult[]> {\n    return await BenchmarkResult.query().orderBy('created_at', 'desc')\n  }\n\n  /**\n   * Get a specific benchmark result by ID\n   */\n  async getResultById(benchmarkId: string): Promise<BenchmarkResult | null> {\n    return await BenchmarkResult.findBy('benchmark_id', benchmarkId)\n  }\n\n  /**\n   * Submit benchmark results to central repository\n   */\n  async submitToRepository(benchmarkId?: string, anonymous?: boolean): Promise<RepositorySubmitResponse> {\n    const result = benchmarkId\n      ? await this.getResultById(benchmarkId)\n      : await this.getLatestResult()\n\n    if (!result) {\n      throw new Error('No benchmark result found to submit')\n    }\n\n    // Only allow full benchmarks with AI data to be submitted to repository\n    if (result.benchmark_type !== 'full') {\n      throw new Error('Only full benchmarks can be shared with the community. Run a Full Benchmark to share your results.')\n    }\n\n    if (!result.ai_tokens_per_second || result.ai_tokens_per_second <= 0) {\n      throw new Error('Benchmark must include AI performance data. Ensure AI Assistant is installed and run a Full Benchmark.')\n    }\n\n    if (result.submitted_to_repository) {\n      throw new Error('Benchmark result has already been submitted')\n    }\n\n    const submission: RepositorySubmission = {\n      cpu_model: result.cpu_model,\n      cpu_cores: result.cpu_cores,\n      cpu_threads: result.cpu_threads,\n      ram_gb: Math.round(result.ram_bytes / (1024 * 1024 * 1024)),\n      disk_type: result.disk_type,\n      gpu_model: result.gpu_model,\n      cpu_score: result.cpu_score,\n      memory_score: result.memory_score,\n      disk_read_score: result.disk_read_score,\n      disk_write_score: result.disk_write_score,\n      ai_tokens_per_second: result.ai_tokens_per_second,\n      ai_time_to_first_token: result.ai_time_to_first_token,\n      nomad_score: result.nomad_score,\n      nomad_version: SystemService.getAppVersion(),\n      benchmark_version: '1.0.0',\n      builder_tag: anonymous ? null : result.builder_tag,\n    }\n\n    try {\n      // Generate HMAC signature for submission verification\n      const timestamp = Date.now().toString()\n      const payload = timestamp + JSON.stringify(submission)\n      const signature = createHmac('sha256', BENCHMARK_HMAC_SECRET)\n        .update(payload)\n        .digest('hex')\n\n      const response = await axios.post(\n        'https://benchmark.projectnomad.us/api/v1/submit',\n        submission,\n        {\n          timeout: 30000,\n          headers: {\n            'X-NOMAD-Timestamp': timestamp,\n            'X-NOMAD-Signature': signature,\n          },\n        }\n      )\n\n      if (response.data.success) {\n        result.submitted_to_repository = true\n        result.submitted_at = DateTime.now()\n        result.repository_id = response.data.repository_id\n        await result.save()\n\n        await BenchmarkSetting.setValue('last_benchmark_run', new Date().toISOString())\n      }\n\n      return response.data as RepositorySubmitResponse\n    } catch (error) {\n      const detail = error.response?.data?.error || error.message || 'Unknown error'\n      const statusCode = error.response?.status\n      logger.error(`Failed to submit benchmark to repository: ${detail} (Status: ${statusCode})`)\n      \n      // Create an error with the status code attached for proper handling upstream\n      const err: any = new Error(`Failed to submit benchmark: ${detail}`)\n      err.statusCode = statusCode\n      throw err\n    }\n  }\n\n  /**\n   * Get comparison stats from central repository\n   */\n  async getComparisonStats(): Promise<RepositoryStats | null> {\n    try {\n      const response = await axios.get('https://benchmark.projectnomad.us/api/v1/stats', {\n        timeout: 10000,\n      })\n      return response.data as RepositoryStats\n    } catch (error) {\n      logger.warn(`Failed to fetch comparison stats: ${error.message}`)\n      return null\n    }\n  }\n\n  /**\n   * Get current benchmark status\n   */\n  getStatus(): { status: BenchmarkStatus; benchmarkId: string | null } {\n    return {\n      status: this.currentStatus,\n      benchmarkId: this.currentBenchmarkId,\n    }\n  }\n\n  /**\n   * Detect system hardware information\n   */\n  async getHardwareInfo(): Promise<HardwareInfo> {\n    this._updateStatus('detecting_hardware', 'Detecting system hardware...')\n\n    try {\n      const [cpu, mem, diskLayout, graphics] = await Promise.all([\n        si.cpu(),\n        si.mem(),\n        si.diskLayout(),\n        si.graphics(),\n      ])\n\n      // Determine disk type from primary disk\n      let diskType: DiskType = 'unknown'\n      if (diskLayout.length > 0) {\n        const primaryDisk = diskLayout[0]\n        if (primaryDisk.type?.toLowerCase().includes('nvme')) {\n          diskType = 'nvme'\n        } else if (primaryDisk.type?.toLowerCase().includes('ssd')) {\n          diskType = 'ssd'\n        } else if (primaryDisk.type?.toLowerCase().includes('hdd') || primaryDisk.interfaceType === 'SATA') {\n          // SATA could be SSD or HDD, check if it's rotational\n          diskType = 'hdd'\n        }\n      }\n\n      // Get GPU model (prefer discrete GPU with dedicated VRAM)\n      let gpuModel: string | null = null\n      if (graphics.controllers && graphics.controllers.length > 0) {\n        // First, look for discrete GPUs (NVIDIA, AMD discrete, or any with significant VRAM)\n        const discreteGpu = graphics.controllers.find((g) => {\n          const vendor = g.vendor?.toLowerCase() || ''\n          const model = g.model?.toLowerCase() || ''\n          // NVIDIA GPUs are always discrete\n          if (vendor.includes('nvidia') || model.includes('geforce') || model.includes('rtx') || model.includes('quadro')) {\n            return true\n          }\n          // AMD discrete GPUs (Radeon, not integrated APU graphics)\n          if ((vendor.includes('amd') || vendor.includes('ati')) &&\n              (model.includes('radeon') || model.includes('rx ') || model.includes('vega')) &&\n              !model.includes('graphics')) {\n            return true\n          }\n          // Any GPU with dedicated VRAM > 512MB is likely discrete\n          if (g.vram && g.vram > 512) {\n            return true\n          }\n          return false\n        })\n        gpuModel = discreteGpu?.model || graphics.controllers[0]?.model || null\n      }\n\n      // Fallback: Check Docker for nvidia runtime and query GPU model via nvidia-smi\n      if (!gpuModel) {\n        try {\n          const dockerInfo = await this.dockerService.docker.info()\n          const runtimes = dockerInfo.Runtimes || {}\n          if ('nvidia' in runtimes) {\n            logger.info('[BenchmarkService] NVIDIA container runtime detected, querying GPU model via nvidia-smi')\n\n            const systemService = new (await import('./system_service.js')).SystemService(this.dockerService)\n            const nvidiaInfo = await systemService.getNvidiaSmiInfo()\n            if (Array.isArray(nvidiaInfo) && nvidiaInfo.length > 0) {\n              gpuModel = nvidiaInfo[0].model\n            } else {\n              logger.warn(`[BenchmarkService] NVIDIA runtime detected but failed to get GPU info: ${typeof nvidiaInfo === 'string' ? nvidiaInfo : JSON.stringify(nvidiaInfo)}`)\n            }\n          }\n        } catch (dockerError) {\n          logger.warn(`[BenchmarkService] Could not query Docker info for GPU detection: ${dockerError.message}`)\n        }\n      }\n\n      // Fallback: Extract integrated GPU from CPU model name\n      if (!gpuModel) {\n        const cpuFullName = `${cpu.manufacturer} ${cpu.brand}`\n\n        // AMD APUs: e.g., \"AMD Ryzen AI 9 HX 370 w/ Radeon 890M\" -> \"Radeon 890M\"\n        const radeonMatch = cpuFullName.match(/w\\/\\s*(Radeon\\s+\\d+\\w*)/i)\n        if (radeonMatch) {\n          gpuModel = radeonMatch[1]\n        }\n\n        // Intel Core Ultra: These have Intel Arc Graphics integrated\n        // e.g., \"Intel Core Ultra 9 285HX\" -> \"Intel Arc Graphics (Integrated)\"\n        if (!gpuModel && cpu.manufacturer?.toLowerCase().includes('intel')) {\n          if (cpu.brand?.toLowerCase().includes('core ultra')) {\n            gpuModel = 'Intel Arc Graphics (Integrated)'\n          }\n        }\n      }\n\n      return {\n        cpu_model: `${cpu.manufacturer} ${cpu.brand}`,\n        cpu_cores: cpu.physicalCores,\n        cpu_threads: cpu.cores,\n        ram_bytes: mem.total,\n        disk_type: diskType,\n        gpu_model: gpuModel,\n      }\n    } catch (error) {\n      logger.error(`Error detecting hardware: ${error.message}`)\n      throw new Error(`Failed to detect hardware: ${error.message}`)\n    }\n  }\n\n  /**\n   * Main benchmark execution method\n   */\n  private async _runBenchmark(type: BenchmarkType, includeAI: boolean): Promise<BenchmarkResult> {\n    if (this.currentStatus !== 'idle') {\n      throw new Error('A benchmark is already running')\n    }\n\n    this.currentBenchmarkId = randomUUID()\n    this._updateStatus('starting', 'Starting benchmark...')\n\n    try {\n      // Detect hardware\n      const hardware = await this.getHardwareInfo()\n\n      // Run system benchmarks\n      let systemScores: SystemScores = {\n        cpu_score: 0,\n        memory_score: 0,\n        disk_read_score: 0,\n        disk_write_score: 0,\n      }\n\n      if (type === 'full' || type === 'system') {\n        systemScores = await this._runSystemBenchmarks()\n      }\n\n      // Run AI benchmark if requested and Ollama is available\n      let aiScores: Partial<AIScores> = {}\n      if (includeAI && (type === 'full' || type === 'ai')) {\n        try {\n          aiScores = await this._runAIBenchmark()\n        } catch (error) {\n          // For AI-only benchmarks, failing is fatal - don't save useless results with all zeros\n          if (type === 'ai') {\n            throw new Error(`AI benchmark failed: ${error.message}. Make sure AI Assistant is installed and running.`)\n          }\n          // For full benchmarks, AI is optional - continue without it\n          logger.warn(`AI benchmark skipped: ${error.message}`)\n        }\n      }\n\n      // Calculate NOMAD score\n      this._updateStatus('calculating_score', 'Calculating NOMAD score...')\n      const nomadScore = this._calculateNomadScore(systemScores, aiScores)\n\n      // Save result\n      const result = await BenchmarkResult.create({\n        benchmark_id: this.currentBenchmarkId,\n        benchmark_type: type,\n        cpu_model: hardware.cpu_model,\n        cpu_cores: hardware.cpu_cores,\n        cpu_threads: hardware.cpu_threads,\n        ram_bytes: hardware.ram_bytes,\n        disk_type: hardware.disk_type,\n        gpu_model: hardware.gpu_model,\n        cpu_score: systemScores.cpu_score,\n        memory_score: systemScores.memory_score,\n        disk_read_score: systemScores.disk_read_score,\n        disk_write_score: systemScores.disk_write_score,\n        ai_tokens_per_second: aiScores.ai_tokens_per_second || null,\n        ai_model_used: aiScores.ai_model_used || null,\n        ai_time_to_first_token: aiScores.ai_time_to_first_token || null,\n        nomad_score: nomadScore,\n        submitted_to_repository: false,\n      })\n\n      this._updateStatus('completed', 'Benchmark completed successfully')\n      this.currentStatus = 'idle'\n      this.currentBenchmarkId = null\n\n      return result\n    } catch (error) {\n      this._updateStatus('error', `Benchmark failed: ${error.message}`)\n      this.currentStatus = 'idle'\n      this.currentBenchmarkId = null\n      throw error\n    }\n  }\n\n  /**\n   * Run system benchmarks using sysbench in Docker\n   */\n  private async _runSystemBenchmarks(): Promise<SystemScores> {\n    // Ensure sysbench image is available\n    await this._ensureSysbenchImage()\n\n    // Run CPU benchmark\n    this._updateStatus('running_cpu', 'Running CPU benchmark...')\n    const cpuResult = await this._runSysbenchCpu()\n\n    // Run memory benchmark\n    this._updateStatus('running_memory', 'Running memory benchmark...')\n    const memoryResult = await this._runSysbenchMemory()\n\n    // Run disk benchmarks\n    this._updateStatus('running_disk_read', 'Running disk read benchmark...')\n    const diskReadResult = await this._runSysbenchDiskRead()\n\n    this._updateStatus('running_disk_write', 'Running disk write benchmark...')\n    const diskWriteResult = await this._runSysbenchDiskWrite()\n\n    // Normalize scores to 0-100 scale\n    return {\n      cpu_score: this._normalizeScore(cpuResult.events_per_second, REFERENCE_SCORES.cpu_events_per_second),\n      memory_score: this._normalizeScore(memoryResult.operations_per_second, REFERENCE_SCORES.memory_ops_per_second),\n      disk_read_score: this._normalizeScore(diskReadResult.read_mb_per_sec, REFERENCE_SCORES.disk_read_mb_per_sec),\n      disk_write_score: this._normalizeScore(diskWriteResult.write_mb_per_sec, REFERENCE_SCORES.disk_write_mb_per_sec),\n    }\n  }\n\n  /**\n   * Run AI benchmark using Ollama\n   */\n  private async _runAIBenchmark(): Promise<AIScores> {\n    try {\n\n    this._updateStatus('running_ai', 'Running AI benchmark...')\n\n    const ollamaAPIURL = await this.dockerService.getServiceURL(SERVICE_NAMES.OLLAMA)\n    if (!ollamaAPIURL) {\n      throw new Error('AI Assistant service location could not be determined. Ensure AI Assistant is installed and running.')\n    }\n\n    // Check if Ollama is available\n    try {\n      await axios.get(`${ollamaAPIURL}/api/tags`, { timeout: 5000 })\n    } catch (error) {\n      const errorCode = error.code || error.response?.status || 'unknown'\n      throw new Error(`Ollama is not running or not accessible (${errorCode}). Ensure AI Assistant is installed and running.`)\n    }\n\n    // Check if the benchmark model is available, pull if not\n    const ollamaService = new (await import('./ollama_service.js')).OllamaService()\n    const modelResponse = await ollamaService.downloadModel(AI_BENCHMARK_MODEL)\n    if (!modelResponse.success) {\n      throw new Error(`Model does not exist and failed to download: ${modelResponse.message}`)\n    }\n\n    // Run inference benchmark\n    const startTime = Date.now()\n\n      const response = await axios.post(\n        `${ollamaAPIURL}/api/generate`,\n        {\n          model: AI_BENCHMARK_MODEL,\n          prompt: AI_BENCHMARK_PROMPT,\n          stream: false,\n        },\n        { timeout: 120000 }\n      )\n\n      const endTime = Date.now()\n      const totalTime = (endTime - startTime) / 1000 // seconds\n\n      // Ollama returns eval_count (tokens generated) and eval_duration (nanoseconds)\n      if (response.data.eval_count && response.data.eval_duration) {\n        const tokenCount = response.data.eval_count\n        const evalDurationSeconds = response.data.eval_duration / 1e9\n        const tokensPerSecond = tokenCount / evalDurationSeconds\n\n        // Time to first token from prompt_eval_duration\n        const ttft = response.data.prompt_eval_duration\n          ? response.data.prompt_eval_duration / 1e6 // Convert to ms\n          : (totalTime * 1000) / 2 // Estimate if not available\n\n        return {\n          ai_tokens_per_second: Math.round(tokensPerSecond * 100) / 100,\n          ai_model_used: AI_BENCHMARK_MODEL,\n          ai_time_to_first_token: Math.round(ttft * 100) / 100,\n        }\n      }\n\n      // Fallback calculation\n      const estimatedTokens = response.data.response?.split(' ').length * 1.3 || 100\n      const tokensPerSecond = estimatedTokens / totalTime\n\n      return {\n        ai_tokens_per_second: Math.round(tokensPerSecond * 100) / 100,\n        ai_model_used: AI_BENCHMARK_MODEL,\n        ai_time_to_first_token: Math.round((totalTime * 1000) / 2),\n      }\n    } catch (error) {\n      throw new Error(`AI benchmark failed: ${error.message}`)\n    }\n  }\n\n  /**\n   * Calculate weighted NOMAD score\n   */\n  private _calculateNomadScore(systemScores: SystemScores, aiScores: Partial<AIScores>): number {\n    let totalWeight = 0\n    let weightedSum = 0\n\n    // CPU score\n    weightedSum += systemScores.cpu_score * SCORE_WEIGHTS.cpu\n    totalWeight += SCORE_WEIGHTS.cpu\n\n    // Memory score\n    weightedSum += systemScores.memory_score * SCORE_WEIGHTS.memory\n    totalWeight += SCORE_WEIGHTS.memory\n\n    // Disk scores\n    weightedSum += systemScores.disk_read_score * SCORE_WEIGHTS.disk_read\n    totalWeight += SCORE_WEIGHTS.disk_read\n    weightedSum += systemScores.disk_write_score * SCORE_WEIGHTS.disk_write\n    totalWeight += SCORE_WEIGHTS.disk_write\n\n    // AI scores (if available)\n    if (aiScores.ai_tokens_per_second !== undefined && aiScores.ai_tokens_per_second !== null) {\n      const aiScore = this._normalizeScore(\n        aiScores.ai_tokens_per_second,\n        REFERENCE_SCORES.ai_tokens_per_second\n      )\n      weightedSum += aiScore * SCORE_WEIGHTS.ai_tokens_per_second\n      totalWeight += SCORE_WEIGHTS.ai_tokens_per_second\n    }\n\n    if (aiScores.ai_time_to_first_token !== undefined && aiScores.ai_time_to_first_token !== null) {\n      // For TTFT, lower is better, so we invert the score\n      const ttftScore = this._normalizeScoreInverse(\n        aiScores.ai_time_to_first_token,\n        REFERENCE_SCORES.ai_ttft_ms\n      )\n      weightedSum += ttftScore * SCORE_WEIGHTS.ai_ttft\n      totalWeight += SCORE_WEIGHTS.ai_ttft\n    }\n\n    // Normalize by actual weight used (in case AI benchmarks were skipped)\n    const nomadScore = totalWeight > 0 ? (weightedSum / totalWeight) * 100 : 0\n\n    return Math.round(Math.min(100, Math.max(0, nomadScore)) * 100) / 100\n  }\n\n  /**\n   * Normalize a raw score to 0-100 scale using log scaling\n   * This provides diminishing returns for very high scores\n   */\n  private _normalizeScore(value: number, reference: number): number {\n    if (value <= 0) return 0\n    // Log scale: score = 50 * (1 + log2(value/reference))\n    // This gives 50 at reference value, scales logarithmically\n    const ratio = value / reference\n    const score = 50 * (1 + Math.log2(Math.max(0.01, ratio)))\n    return Math.min(100, Math.max(0, score)) / 100\n  }\n\n  /**\n   * Normalize a score where lower is better (like latency)\n   */\n  private _normalizeScoreInverse(value: number, reference: number): number {\n    if (value <= 0) return 1\n    // Inverse: lower values = higher scores\n    const ratio = reference / value\n    const score = 50 * (1 + Math.log2(Math.max(0.01, ratio)))\n    return Math.min(100, Math.max(0, score)) / 100\n  }\n\n  /**\n   * Ensure sysbench Docker image is available\n   */\n  private async _ensureSysbenchImage(): Promise<void> {\n    try {\n      await this.dockerService.docker.getImage(SYSBENCH_IMAGE).inspect()\n    } catch {\n      this._updateStatus('starting', `Pulling sysbench image...`)\n      const pullStream = await this.dockerService.docker.pull(SYSBENCH_IMAGE)\n      await new Promise((resolve) => this.dockerService.docker.modem.followProgress(pullStream, resolve))\n    }\n  }\n\n  /**\n   * Run sysbench CPU benchmark\n   */\n  private async _runSysbenchCpu(): Promise<SysbenchCpuResult> {\n    const output = await this._runSysbenchCommand([\n      'sysbench',\n      'cpu',\n      '--cpu-max-prime=20000',\n      '--threads=4',\n      '--time=30',\n      'run',\n    ])\n\n    // Parse output for events per second\n    const eventsMatch = output.match(/events per second:\\s*([\\d.]+)/i)\n    const totalTimeMatch = output.match(/total time:\\s*([\\d.]+)s/i)\n    const totalEventsMatch = output.match(/total number of events:\\s*(\\d+)/i)\n\n    return {\n      events_per_second: eventsMatch ? parseFloat(eventsMatch[1]) : 0,\n      total_time: totalTimeMatch ? parseFloat(totalTimeMatch[1]) : 30,\n      total_events: totalEventsMatch ? parseInt(totalEventsMatch[1]) : 0,\n    }\n  }\n\n  /**\n   * Run sysbench memory benchmark\n   */\n  private async _runSysbenchMemory(): Promise<SysbenchMemoryResult> {\n    const output = await this._runSysbenchCommand([\n      'sysbench',\n      'memory',\n      '--memory-block-size=1K',\n      '--memory-total-size=10G',\n      '--threads=4',\n      'run',\n    ])\n\n    // Parse output\n    const opsMatch = output.match(/Total operations:\\s*\\d+\\s*\\(([\\d.]+)\\s*per second\\)/i)\n    const transferMatch = output.match(/([\\d.]+)\\s*MiB\\/sec/i)\n    const timeMatch = output.match(/total time:\\s*([\\d.]+)s/i)\n\n    return {\n      operations_per_second: opsMatch ? parseFloat(opsMatch[1]) : 0,\n      transfer_rate_mb_per_sec: transferMatch ? parseFloat(transferMatch[1]) : 0,\n      total_time: timeMatch ? parseFloat(timeMatch[1]) : 0,\n    }\n  }\n\n  /**\n   * Run sysbench disk read benchmark\n   */\n  private async _runSysbenchDiskRead(): Promise<SysbenchDiskResult> {\n    // Run prepare, test, and cleanup in a single container\n    // This is necessary because each container has its own filesystem\n    const output = await this._runSysbenchCommand([\n      'sh',\n      '-c',\n      'sysbench fileio --file-total-size=1G --file-num=4 prepare && ' +\n        'sysbench fileio --file-total-size=1G --file-num=4 --file-test-mode=seqrd --time=30 run && ' +\n        'sysbench fileio --file-total-size=1G --file-num=4 cleanup',\n    ])\n\n    // Parse output - look for the Throughput section\n    const readMatch = output.match(/read,\\s*MiB\\/s:\\s*([\\d.]+)/i)\n    const readsPerSecMatch = output.match(/reads\\/s:\\s*([\\d.]+)/i)\n\n    logger.debug(`[BenchmarkService] Disk read output parsing - read: ${readMatch?.[1]}, reads/s: ${readsPerSecMatch?.[1]}`)\n\n    return {\n      reads_per_second: readsPerSecMatch ? parseFloat(readsPerSecMatch[1]) : 0,\n      writes_per_second: 0,\n      read_mb_per_sec: readMatch ? parseFloat(readMatch[1]) : 0,\n      write_mb_per_sec: 0,\n      total_time: 30,\n    }\n  }\n\n  /**\n   * Run sysbench disk write benchmark\n   */\n  private async _runSysbenchDiskWrite(): Promise<SysbenchDiskResult> {\n    // Run prepare, test, and cleanup in a single container\n    // This is necessary because each container has its own filesystem\n    const output = await this._runSysbenchCommand([\n      'sh',\n      '-c',\n      'sysbench fileio --file-total-size=1G --file-num=4 prepare && ' +\n        'sysbench fileio --file-total-size=1G --file-num=4 --file-test-mode=seqwr --time=30 run && ' +\n        'sysbench fileio --file-total-size=1G --file-num=4 cleanup',\n    ])\n\n    // Parse output - look for the Throughput section\n    const writeMatch = output.match(/written,\\s*MiB\\/s:\\s*([\\d.]+)/i)\n    const writesPerSecMatch = output.match(/writes\\/s:\\s*([\\d.]+)/i)\n\n    logger.debug(`[BenchmarkService] Disk write output parsing - written: ${writeMatch?.[1]}, writes/s: ${writesPerSecMatch?.[1]}`)\n\n    return {\n      reads_per_second: 0,\n      writes_per_second: writesPerSecMatch ? parseFloat(writesPerSecMatch[1]) : 0,\n      read_mb_per_sec: 0,\n      write_mb_per_sec: writeMatch ? parseFloat(writeMatch[1]) : 0,\n      total_time: 30,\n    }\n  }\n\n  /**\n   * Run a sysbench command in a Docker container\n   */\n  private async _runSysbenchCommand(cmd: string[]): Promise<string> {\n    let container: Dockerode.Container | null = null\n    try {\n      // Create container with TTY to avoid multiplexed output\n      container = await this.dockerService.docker.createContainer({\n        Image: SYSBENCH_IMAGE,\n        Cmd: cmd,\n        name: `${SYSBENCH_CONTAINER_NAME}_${Date.now()}`,\n        Tty: true, // Important: prevents multiplexed stdout/stderr headers\n        HostConfig: {\n          AutoRemove: false, // Don't auto-remove to avoid race condition with fetching logs\n        },\n      })\n\n      // Start container\n      await container.start()\n\n      // Wait for completion\n      await container.wait()\n      \n      // Get logs after container has finished\n      const logs = await container.logs({\n        stdout: true,\n        stderr: true,\n      })\n\n      // Parse logs (Docker logs include header bytes)\n      const output = logs.toString('utf8')\n        .replace(/[\\x00-\\x08]/g, '') // Remove control characters\n        .trim()\n\n      // Manually remove the container after getting logs\n      try {\n        await container.remove()\n      } catch (removeError) {\n        // Log but don't fail if removal fails (container might already be gone)\n        logger.warn(`Failed to remove sysbench container: ${removeError.message}`)\n      }\n\n      return output\n    } catch (error) {\n      // Clean up container on error if it exists\n      if (container) {\n        try {\n          await container.remove({ force: true })\n        } catch (removeError) {\n          // Ignore removal errors\n        }\n      }\n      logger.error(`Sysbench command failed: ${error.message}`)\n      throw new Error(`Sysbench command failed: ${error.message}`)\n    }\n  }\n\n  /**\n   * Broadcast benchmark progress update\n   */\n  private _updateStatus(status: BenchmarkStatus, message: string) {\n    this.currentStatus = status\n\n    const progress: BenchmarkProgress = {\n      status,\n      progress: this._getProgressPercent(status),\n      message,\n      current_stage: this._getStageLabel(status),\n      timestamp: new Date().toISOString(),\n    }\n\n    transmit.broadcast(BROADCAST_CHANNELS.BENCHMARK_PROGRESS, {\n      benchmark_id: this.currentBenchmarkId,\n      ...progress,\n    })\n\n    logger.info(`[BenchmarkService] ${status}: ${message}`)\n  }\n\n  /**\n   * Get progress percentage for a given status\n   */\n  private _getProgressPercent(status: BenchmarkStatus): number {\n    const progressMap: Record<BenchmarkStatus, number> = {\n      idle: 0,\n      starting: 5,\n      detecting_hardware: 10,\n      running_cpu: 25,\n      running_memory: 40,\n      running_disk_read: 55,\n      running_disk_write: 70,\n      downloading_ai_model: 80,\n      running_ai: 85,\n      calculating_score: 95,\n      completed: 100,\n      error: 0,\n    }\n    return progressMap[status] || 0\n  }\n\n  /**\n   * Get human-readable stage label\n   */\n  private _getStageLabel(status: BenchmarkStatus): string {\n    const labelMap: Record<BenchmarkStatus, string> = {\n      idle: 'Idle',\n      starting: 'Starting',\n      detecting_hardware: 'Detecting Hardware',\n      running_cpu: 'CPU Benchmark',\n      running_memory: 'Memory Benchmark',\n      running_disk_read: 'Disk Read Test',\n      running_disk_write: 'Disk Write Test',\n      downloading_ai_model: 'Downloading AI Model',\n      running_ai: 'AI Inference Test',\n      calculating_score: 'Calculating Score',\n      completed: 'Complete',\n      error: 'Error',\n    }\n    return labelMap[status] || status\n  }\n}\n"
  },
  {
    "path": "admin/app/services/chat_service.ts",
    "content": "import ChatSession from '#models/chat_session'\nimport ChatMessage from '#models/chat_message'\nimport logger from '@adonisjs/core/services/logger'\nimport { DateTime } from 'luxon'\nimport { inject } from '@adonisjs/core'\nimport { OllamaService } from './ollama_service.js'\nimport { DEFAULT_QUERY_REWRITE_MODEL, SYSTEM_PROMPTS } from '../../constants/ollama.js'\nimport { toTitleCase } from '../utils/misc.js'\n\n@inject()\nexport class ChatService {\n  constructor(private ollamaService: OllamaService) {}\n\n  async getAllSessions() {\n    try {\n      const sessions = await ChatSession.query().orderBy('updated_at', 'desc')\n      return sessions.map((session) => ({\n        id: session.id.toString(),\n        title: session.title,\n        model: session.model,\n        timestamp: session.updated_at.toJSDate(),\n        lastMessage: null, // Will be populated from messages if needed\n      }))\n    } catch (error) {\n      logger.error(\n        `[ChatService] Failed to get sessions: ${error instanceof Error ? error.message : error}`\n      )\n      return []\n    }\n  }\n\n  async getChatSuggestions() {\n    try {\n      const models = await this.ollamaService.getModels()\n      if (!models || models.length === 0) {\n        return [] // If no models are available, return empty suggestions\n      }\n\n      // Larger models generally give \"better\" responses, so pick the largest one\n      const largestModel = models.reduce((prev, current) => {\n        return prev.size > current.size ? prev : current\n      })\n\n      if (!largestModel) {\n        return []\n      }\n\n      const response = await this.ollamaService.chat({\n        model: largestModel.name,\n        messages: [\n          {\n            role: 'user',\n            content: SYSTEM_PROMPTS.chat_suggestions,\n          }\n        ],\n        stream: false,\n      })\n\n      if (response && response.message && response.message.content) {\n        const content = response.message.content.trim()\n        \n        // Handle both comma-separated and newline-separated formats\n        let suggestions: string[] = []\n        \n        // Try splitting by commas first\n        if (content.includes(',')) {\n          suggestions = content.split(',').map((s) => s.trim())\n        } \n        // Fall back to newline separation\n        else {\n          suggestions = content\n            .split(/\\r?\\n/)\n            .map((s) => s.trim())\n            // Remove numbered list markers (1., 2., 3., etc.) and bullet points\n            .map((s) => s.replace(/^\\d+\\.\\s*/, '').replace(/^[-*•]\\s*/, ''))\n            // Remove surrounding quotes if present\n            .map((s) => s.replace(/^[\"']|[\"']$/g, ''))\n        }\n        \n        // Filter out empty strings and limit to 3 suggestions\n        const filtered =  suggestions\n          .filter((s) => s.length > 0)\n          .slice(0, 3)\n\n        return filtered.map((s) => toTitleCase(s))\n      } else {\n        return []\n      }\n    } catch (error) {\n      logger.error(\n        `[ChatService] Failed to get chat suggestions: ${\n          error instanceof Error ? error.message : error\n        }`\n      )\n      return []\n    }\n  }\n\n  async getSession(sessionId: number) {\n    try {\n      const session = await ChatSession.query().where('id', sessionId).preload('messages').first()\n\n      if (!session) {\n        return null\n      }\n\n      return {\n        id: session.id.toString(),\n        title: session.title,\n        model: session.model,\n        timestamp: session.updated_at.toJSDate(),\n        messages: session.messages.map((msg) => ({\n          id: msg.id.toString(),\n          role: msg.role,\n          content: msg.content,\n          timestamp: msg.created_at.toJSDate(),\n        })),\n      }\n    } catch (error) {\n      logger.error(\n        `[ChatService] Failed to get session ${sessionId}: ${\n          error instanceof Error ? error.message : error\n        }`\n      )\n      return null\n    }\n  }\n\n  async createSession(title: string, model?: string) {\n    try {\n      const session = await ChatSession.create({\n        title,\n        model: model || null,\n      })\n\n      return {\n        id: session.id.toString(),\n        title: session.title,\n        model: session.model,\n        timestamp: session.created_at.toJSDate(),\n      }\n    } catch (error) {\n      logger.error(\n        `[ChatService] Failed to create session: ${error instanceof Error ? error.message : error}`\n      )\n      throw new Error('Failed to create chat session')\n    }\n  }\n\n  async updateSession(sessionId: number, data: { title?: string; model?: string }) {\n    try {\n      const session = await ChatSession.findOrFail(sessionId)\n\n      if (data.title) {\n        session.title = data.title\n      }\n      if (data.model !== undefined) {\n        session.model = data.model\n      }\n\n      await session.save()\n\n      return {\n        id: session.id.toString(),\n        title: session.title,\n        model: session.model,\n        timestamp: session.updated_at.toJSDate(),\n      }\n    } catch (error) {\n      logger.error(\n        `[ChatService] Failed to update session ${sessionId}: ${\n          error instanceof Error ? error.message : error\n        }`\n      )\n      throw new Error('Failed to update chat session')\n    }\n  }\n\n  async addMessage(sessionId: number, role: 'system' | 'user' | 'assistant', content: string) {\n    try {\n      const message = await ChatMessage.create({\n        session_id: sessionId,\n        role,\n        content,\n      })\n\n      // Update session's updated_at timestamp\n      const session = await ChatSession.findOrFail(sessionId)\n      session.updated_at = DateTime.now()\n      await session.save()\n\n      return {\n        id: message.id.toString(),\n        role: message.role,\n        content: message.content,\n        timestamp: message.created_at.toJSDate(),\n      }\n    } catch (error) {\n      logger.error(\n        `[ChatService] Failed to add message to session ${sessionId}: ${\n          error instanceof Error ? error.message : error\n        }`\n      )\n      throw new Error('Failed to add message')\n    }\n  }\n\n  async deleteSession(sessionId: number) {\n    try {\n      const session = await ChatSession.findOrFail(sessionId)\n      await session.delete()\n      return { success: true }\n    } catch (error) {\n      logger.error(\n        `[ChatService] Failed to delete session ${sessionId}: ${\n          error instanceof Error ? error.message : error\n        }`\n      )\n      throw new Error('Failed to delete chat session')\n    }\n  }\n\n  async getMessageCount(sessionId: number): Promise<number> {\n    try {\n      const count = await ChatMessage.query().where('session_id', sessionId).count('* as total')\n      return Number(count[0].$extras.total)\n    } catch (error) {\n      logger.error(\n        `[ChatService] Failed to get message count for session ${sessionId}: ${error instanceof Error ? error.message : error}`\n      )\n      return 0\n    }\n  }\n\n  async generateTitle(sessionId: number, userMessage: string, assistantMessage: string) {\n    try {\n      const models = await this.ollamaService.getModels()\n      const titleModelAvailable = models?.some((m) => m.name === DEFAULT_QUERY_REWRITE_MODEL)\n\n      let title: string\n\n      if (!titleModelAvailable) {\n        title = userMessage.slice(0, 57) + (userMessage.length > 57 ? '...' : '')\n      } else {\n        const response = await this.ollamaService.chat({\n          model: DEFAULT_QUERY_REWRITE_MODEL,\n          messages: [\n            { role: 'system', content: SYSTEM_PROMPTS.title_generation },\n            { role: 'user', content: userMessage },\n            { role: 'assistant', content: assistantMessage },\n          ],\n        })\n\n        title = response?.message?.content?.trim()\n        if (!title) {\n          title = userMessage.slice(0, 57) + (userMessage.length > 57 ? '...' : '')\n        }\n      }\n\n      await this.updateSession(sessionId, { title })\n      logger.info(`[ChatService] Generated title for session ${sessionId}: \"${title}\"`)\n    } catch (error) {\n      logger.error(\n        `[ChatService] Failed to generate title for session ${sessionId}: ${error instanceof Error ? error.message : error}`\n      )\n      // Fall back to truncated user message\n      try {\n        const fallbackTitle = userMessage.slice(0, 57) + (userMessage.length > 57 ? '...' : '')\n        await this.updateSession(sessionId, { title: fallbackTitle })\n      } catch {\n        // Silently fail - session keeps \"New Chat\" title\n      }\n    }\n  }\n\n  async deleteAllSessions() {\n    try {\n      await ChatSession.query().delete()\n      return { success: true, message: 'All chat sessions deleted' }\n    } catch (error) {\n      logger.error(\n        `[ChatService] Failed to delete all sessions: ${\n          error instanceof Error ? error.message : error\n        }`\n      )\n      throw new Error('Failed to delete all chat sessions')\n    }\n  }\n}\n"
  },
  {
    "path": "admin/app/services/collection_manifest_service.ts",
    "content": "import axios from 'axios'\nimport vine from '@vinejs/vine'\nimport logger from '@adonisjs/core/services/logger'\nimport { DateTime } from 'luxon'\nimport { join } from 'path'\nimport CollectionManifest from '#models/collection_manifest'\nimport InstalledResource from '#models/installed_resource'\nimport { zimCategoriesSpecSchema, mapsSpecSchema, wikipediaSpecSchema } from '#validators/curated_collections'\nimport {\n  ensureDirectoryExists,\n  listDirectoryContents,\n  getFileStatsIfExists,\n  ZIM_STORAGE_PATH,\n} from '../utils/fs.js'\nimport type {\n  ManifestType,\n  ZimCategoriesSpec,\n  MapsSpec,\n  CategoryWithStatus,\n  CollectionWithStatus,\n  SpecResource,\n  SpecTier,\n} from '../../types/collections.js'\n\nconst SPEC_URLS: Record<ManifestType, string> = {\n  zim_categories: 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/kiwix-categories.json',\n  maps: 'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/main/collections/maps.json',\n  wikipedia: 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/wikipedia.json',\n}\n\nconst VALIDATORS: Record<ManifestType, any> = {\n  zim_categories: zimCategoriesSpecSchema,\n  maps: mapsSpecSchema,\n  wikipedia: wikipediaSpecSchema,\n}\n\nexport class CollectionManifestService {\n  private readonly mapStoragePath = '/storage/maps'\n\n  // ---- Spec management ----\n\n  async fetchAndCacheSpec(type: ManifestType): Promise<boolean> {\n    try {\n      const response = await axios.get(SPEC_URLS[type], { timeout: 15000 })\n\n      const validated = await vine.validate({\n        schema: VALIDATORS[type],\n        data: response.data,\n      })\n\n      const existing = await CollectionManifest.find(type)\n      const specVersion = validated.spec_version\n\n      if (existing) {\n        const changed = existing.spec_version !== specVersion\n        existing.spec_version = specVersion\n        existing.spec_data = validated\n        existing.fetched_at = DateTime.now()\n        await existing.save()\n        return changed\n      }\n\n      await CollectionManifest.create({\n        type,\n        spec_version: specVersion,\n        spec_data: validated,\n        fetched_at: DateTime.now(),\n      })\n\n      return true\n    } catch (error) {\n      logger.error(`[CollectionManifestService] Failed to fetch spec for ${type}:`, error?.message || error)\n      return false\n    }\n  }\n\n  async getCachedSpec<T>(type: ManifestType): Promise<T | null> {\n    const manifest = await CollectionManifest.find(type)\n    if (!manifest) return null\n    return manifest.spec_data as T\n  }\n\n  async getSpecWithFallback<T>(type: ManifestType): Promise<T | null> {\n    try {\n      await this.fetchAndCacheSpec(type)\n    } catch {\n      // Fetch failed, will fall back to cache\n    }\n    return this.getCachedSpec<T>(type)\n  }\n\n  // ---- Status computation ----\n\n  async getCategoriesWithStatus(): Promise<CategoryWithStatus[]> {\n    const spec = await this.getSpecWithFallback<ZimCategoriesSpec>('zim_categories')\n    if (!spec) return []\n\n    const installedResources = await InstalledResource.query().where('resource_type', 'zim')\n    const installedMap = new Map(installedResources.map((r) => [r.resource_id, r]))\n\n    return spec.categories.map((category) => ({\n      ...category,\n      installedTierSlug: this.getInstalledTierForCategory(category.tiers, installedMap),\n    }))\n  }\n\n  async getMapCollectionsWithStatus(): Promise<CollectionWithStatus[]> {\n    const spec = await this.getSpecWithFallback<MapsSpec>('maps')\n    if (!spec) return []\n\n    const installedResources = await InstalledResource.query().where('resource_type', 'map')\n    const installedIds = new Set(installedResources.map((r) => r.resource_id))\n\n    return spec.collections.map((collection) => {\n      const installedCount = collection.resources.filter((r) => installedIds.has(r.id)).length\n      return {\n        ...collection,\n        all_installed: installedCount === collection.resources.length,\n        installed_count: installedCount,\n        total_count: collection.resources.length,\n      }\n    })\n  }\n\n  // ---- Tier resolution ----\n\n  static resolveTierResources(tier: SpecTier, allTiers: SpecTier[]): SpecResource[] {\n    const visited = new Set<string>()\n    return CollectionManifestService._resolveTierResourcesInner(tier, allTiers, visited)\n  }\n\n  private static _resolveTierResourcesInner(\n    tier: SpecTier,\n    allTiers: SpecTier[],\n    visited: Set<string>\n  ): SpecResource[] {\n    if (visited.has(tier.slug)) return [] // cycle detection\n    visited.add(tier.slug)\n\n    const resources: SpecResource[] = []\n\n    if (tier.includesTier) {\n      const included = allTiers.find((t) => t.slug === tier.includesTier)\n      if (included) {\n        resources.push(...CollectionManifestService._resolveTierResourcesInner(included, allTiers, visited))\n      }\n    }\n\n    resources.push(...tier.resources)\n    return resources\n  }\n\n  getInstalledTierForCategory(\n    tiers: SpecTier[],\n    installedMap: Map<string, InstalledResource>\n  ): string | undefined {\n    // Check from highest tier to lowest (tiers are ordered low to high in spec)\n    const reversedTiers = [...tiers].reverse()\n\n    for (const tier of reversedTiers) {\n      const resolved = CollectionManifestService.resolveTierResources(tier, tiers)\n      if (resolved.length === 0) continue\n\n      const allInstalled = resolved.every((r) => installedMap.has(r.id))\n      if (allInstalled) {\n        return tier.slug\n      }\n    }\n\n    return undefined\n  }\n\n  // ---- Filename parsing ----\n\n  static parseZimFilename(filename: string): { resource_id: string; version: string } | null {\n    const name = filename.replace(/\\.zim$/, '')\n    const match = name.match(/^(.+)_(\\d{4}-\\d{2})$/)\n    if (!match) return null\n    return { resource_id: match[1], version: match[2] }\n  }\n\n  static parseMapFilename(filename: string): { resource_id: string; version: string } | null {\n    const name = filename.replace(/\\.pmtiles$/, '')\n    const match = name.match(/^(.+)_(\\d{4}-\\d{2})$/)\n    if (!match) return null\n    return { resource_id: match[1], version: match[2] }\n  }\n\n  // ---- Filesystem reconciliation ----\n\n  async reconcileFromFilesystem(): Promise<{ zim: number; map: number }> {\n    let zimCount = 0\n    let mapCount = 0\n\n    console.log(\"RECONCILING FILESYSTEM MANIFESTS...\")\n\n    // Reconcile ZIM files\n    try {\n      const zimDir = join(process.cwd(), ZIM_STORAGE_PATH)\n      await ensureDirectoryExists(zimDir)\n      const zimItems = await listDirectoryContents(zimDir)\n      const zimFiles = zimItems.filter((f) => f.name.endsWith('.zim'))\n\n      console.log(`Found ${zimFiles.length} ZIM files on disk. Reconciling with database...`)\n\n      // Get spec for URL lookup\n      const zimSpec = await this.getCachedSpec<ZimCategoriesSpec>('zim_categories')\n      const specResourceMap = new Map<string, SpecResource>()\n      if (zimSpec) {\n        for (const cat of zimSpec.categories) {\n          for (const tier of cat.tiers) {\n            for (const res of tier.resources) {\n              specResourceMap.set(res.id, res)\n            }\n          }\n        }\n      }\n\n      const seenZimIds = new Set<string>()\n\n      for (const file of zimFiles) {\n        console.log(`Processing ZIM file: ${file.name}`)\n        // Skip Wikipedia files (managed by WikipediaSelection model)\n        if (file.name.startsWith('wikipedia_en_')) continue\n\n        const parsed = CollectionManifestService.parseZimFilename(file.name)\n        console.log(`Parsed ZIM filename:`, parsed)\n        if (!parsed) continue\n\n        seenZimIds.add(parsed.resource_id)\n\n        const specRes = specResourceMap.get(parsed.resource_id)\n        const filePath = join(zimDir, file.name)\n        const stats = await getFileStatsIfExists(filePath)\n\n        await InstalledResource.updateOrCreate(\n          { resource_id: parsed.resource_id, resource_type: 'zim' },\n          {\n            version: parsed.version,\n            url: specRes?.url || '',\n            file_path: filePath,\n            file_size_bytes: stats ? Number(stats.size) : null,\n            installed_at: DateTime.now(),\n          }\n        )\n        zimCount++\n      }\n\n      // Remove entries for ZIM files no longer on disk\n      const existingZim = await InstalledResource.query().where('resource_type', 'zim')\n      for (const entry of existingZim) {\n        if (!seenZimIds.has(entry.resource_id)) {\n          await entry.delete()\n        }\n      }\n    } catch (error) {\n      logger.error('[CollectionManifestService] Error reconciling ZIM files:', error)\n    }\n\n    // Reconcile map files\n    try {\n      const mapDir = join(process.cwd(), this.mapStoragePath, 'pmtiles')\n      await ensureDirectoryExists(mapDir)\n      const mapItems = await listDirectoryContents(mapDir)\n      const mapFiles = mapItems.filter((f) => f.name.endsWith('.pmtiles'))\n\n      // Get spec for URL/version lookup\n      const mapSpec = await this.getCachedSpec<MapsSpec>('maps')\n      const mapResourceMap = new Map<string, SpecResource>()\n      if (mapSpec) {\n        for (const col of mapSpec.collections) {\n          for (const res of col.resources) {\n            mapResourceMap.set(res.id, res)\n          }\n        }\n      }\n\n      const seenMapIds = new Set<string>()\n\n      for (const file of mapFiles) {\n        const parsed = CollectionManifestService.parseMapFilename(file.name)\n        if (!parsed) continue\n\n        seenMapIds.add(parsed.resource_id)\n\n        const specRes = mapResourceMap.get(parsed.resource_id)\n        const filePath = join(mapDir, file.name)\n        const stats = await getFileStatsIfExists(filePath)\n\n        await InstalledResource.updateOrCreate(\n          { resource_id: parsed.resource_id, resource_type: 'map' },\n          {\n            version: parsed.version,\n            url: specRes?.url || '',\n            file_path: filePath,\n            file_size_bytes: stats ? Number(stats.size) : null,\n            installed_at: DateTime.now(),\n          }\n        )\n        mapCount++\n      }\n\n      // Remove entries for map files no longer on disk\n      const existingMaps = await InstalledResource.query().where('resource_type', 'map')\n      for (const entry of existingMaps) {\n        if (!seenMapIds.has(entry.resource_id)) {\n          await entry.delete()\n        }\n      }\n    } catch (error) {\n      logger.error('[CollectionManifestService] Error reconciling map files:', error)\n    }\n\n    logger.info(`[CollectionManifestService] Reconciled ${zimCount} ZIM files, ${mapCount} map files`)\n    return { zim: zimCount, map: mapCount }\n  }\n}\n"
  },
  {
    "path": "admin/app/services/collection_update_service.ts",
    "content": "import logger from '@adonisjs/core/services/logger'\nimport env from '#start/env'\nimport axios from 'axios'\nimport InstalledResource from '#models/installed_resource'\nimport { RunDownloadJob } from '../jobs/run_download_job.js'\nimport { ZIM_STORAGE_PATH } from '../utils/fs.js'\nimport { join } from 'path'\nimport type {\n  ResourceUpdateCheckRequest,\n  ResourceUpdateInfo,\n  ContentUpdateCheckResult,\n} from '../../types/collections.js'\nimport { NOMAD_API_DEFAULT_BASE_URL } from '../../constants/misc.js'\n\nconst MAP_STORAGE_PATH = '/storage/maps'\n\nconst ZIM_MIME_TYPES = ['application/x-zim', 'application/x-openzim', 'application/octet-stream']\nconst PMTILES_MIME_TYPES = ['application/vnd.pmtiles', 'application/octet-stream']\n\nexport class CollectionUpdateService {\n  async checkForUpdates(): Promise<ContentUpdateCheckResult> {\n    const nomadAPIURL = env.get('NOMAD_API_URL') || NOMAD_API_DEFAULT_BASE_URL\n    if (!nomadAPIURL) {\n      return {\n        updates: [],\n        checked_at: new Date().toISOString(),\n        error: 'Nomad API is not configured. Set the NOMAD_API_URL environment variable.',\n      }\n    }\n\n    const installed = await InstalledResource.all()\n    if (installed.length === 0) {\n      return {\n        updates: [],\n        checked_at: new Date().toISOString(),\n      }\n    }\n\n    const requestBody: ResourceUpdateCheckRequest = {\n      resources: installed.map((r) => ({\n        resource_id: r.resource_id,\n        resource_type: r.resource_type,\n        installed_version: r.version,\n      })),\n    }\n\n    try {\n      const response = await axios.post<ResourceUpdateInfo[]>(`${nomadAPIURL}/api/v1/resources/check-updates`, requestBody, {\n        timeout: 15000,\n      })\n\n      logger.info(\n        `[CollectionUpdateService] Update check complete: ${response.data.length} update(s) available`\n      )\n\n      return {\n        updates: response.data,\n        checked_at: new Date().toISOString(),\n      }\n    } catch (error) {\n      if (axios.isAxiosError(error) && error.response) {\n        logger.error(\n          `[CollectionUpdateService] Nomad API returned ${error.response.status}: ${JSON.stringify(error.response.data)}`\n        )\n        return {\n          updates: [],\n          checked_at: new Date().toISOString(),\n          error: `Nomad API returned status ${error.response.status}`,\n        }\n      }\n      const message =\n        error instanceof Error ? error.message : 'Unknown error contacting Nomad API'\n      logger.error(`[CollectionUpdateService] Failed to check for updates: ${message}`)\n      return {\n        updates: [],\n        checked_at: new Date().toISOString(),\n        error: `Failed to contact Nomad API: ${message}`,\n      }\n    }\n  }\n\n  async applyUpdate(\n    update: ResourceUpdateInfo\n  ): Promise<{ success: boolean; jobId?: string; error?: string }> {\n    // Check if a download is already in progress for this URL\n    const existingJob = await RunDownloadJob.getByUrl(update.download_url)\n    if (existingJob) {\n      const state = await existingJob.getState()\n      if (state === 'active' || state === 'waiting' || state === 'delayed') {\n        return {\n          success: false,\n          error: `A download is already in progress for ${update.resource_id}`,\n        }\n      }\n    }\n\n    const filename = this.buildFilename(update)\n    const filepath = this.buildFilepath(update, filename)\n\n    const result = await RunDownloadJob.dispatch({\n      url: update.download_url,\n      filepath,\n      timeout: 30000,\n      allowedMimeTypes:\n        update.resource_type === 'zim' ? ZIM_MIME_TYPES : PMTILES_MIME_TYPES,\n      forceNew: true,\n      filetype: update.resource_type,\n      resourceMetadata: {\n        resource_id: update.resource_id,\n        version: update.latest_version,\n        collection_ref: null,\n      },\n    })\n\n    if (!result || !result.job) {\n      return { success: false, error: 'Failed to dispatch download job' }\n    }\n\n    logger.info(\n      `[CollectionUpdateService] Dispatched update download for ${update.resource_id}: ${update.installed_version} → ${update.latest_version}`\n    )\n\n    return { success: true, jobId: result.job.id }\n  }\n\n  async applyAllUpdates(\n    updates: ResourceUpdateInfo[]\n  ): Promise<{ results: Array<{ resource_id: string; success: boolean; jobId?: string; error?: string }> }> {\n    const results: Array<{\n      resource_id: string\n      success: boolean\n      jobId?: string\n      error?: string\n    }> = []\n\n    for (const update of updates) {\n      const result = await this.applyUpdate(update)\n      results.push({ resource_id: update.resource_id, ...result })\n    }\n\n    return { results }\n  }\n\n  private buildFilename(update: ResourceUpdateInfo): string {\n    if (update.resource_type === 'zim') {\n      return `${update.resource_id}_${update.latest_version}.zim`\n    }\n    return `${update.resource_id}_${update.latest_version}.pmtiles`\n  }\n\n  private buildFilepath(update: ResourceUpdateInfo, filename: string): string {\n    if (update.resource_type === 'zim') {\n      return join(process.cwd(), ZIM_STORAGE_PATH, filename)\n    }\n    return join(process.cwd(), MAP_STORAGE_PATH, 'pmtiles', filename)\n  }\n}\n"
  },
  {
    "path": "admin/app/services/container_registry_service.ts",
    "content": "import logger from '@adonisjs/core/services/logger'\nimport { isNewerVersion, parseMajorVersion } from '../utils/version.js'\n\nexport interface ParsedImageReference {\n  registry: string\n  namespace: string\n  repo: string\n  tag: string\n  /** Full name for registry API calls: namespace/repo */\n  fullName: string\n}\n\nexport interface AvailableUpdate {\n  tag: string\n  isLatest: boolean\n  releaseUrl?: string\n}\n\ninterface TokenCacheEntry {\n  token: string\n  expiresAt: number\n}\n\nconst SEMVER_TAG_PATTERN = /^v?(\\d+\\.\\d+(?:\\.\\d+)?)$/\nconst PLATFORM_SUFFIXES = ['-arm64', '-amd64', '-alpine', '-slim', '-cuda', '-rocm']\nconst REJECTED_TAGS = new Set(['latest', 'nightly', 'edge', 'dev', 'beta', 'alpha', 'canary', 'rc', 'test', 'debug'])\n\nexport class ContainerRegistryService {\n  private tokenCache = new Map<string, TokenCacheEntry>()\n  private sourceUrlCache = new Map<string, string | null>()\n  private releaseTagPrefixCache = new Map<string, string>()\n\n  /**\n   * Parse a Docker image reference string into its components.\n   */\n  parseImageReference(image: string): ParsedImageReference {\n    let registry: string\n    let remainder: string\n    let tag = 'latest'\n\n    // Split off the tag\n    const lastColon = image.lastIndexOf(':')\n    if (lastColon > -1 && !image.substring(lastColon).includes('/')) {\n      tag = image.substring(lastColon + 1)\n      image = image.substring(0, lastColon)\n    }\n\n    // Determine registry vs image path\n    const parts = image.split('/')\n\n    if (parts.length === 1) {\n      // e.g. \"nginx\" → Docker Hub library image\n      registry = 'registry-1.docker.io'\n      remainder = `library/${parts[0]}`\n    } else if (parts.length === 2 && !parts[0].includes('.') && !parts[0].includes(':')) {\n      // e.g. \"ollama/ollama\" → Docker Hub user image\n      registry = 'registry-1.docker.io'\n      remainder = image\n    } else {\n      // e.g. \"ghcr.io/kiwix/kiwix-serve\" → custom registry\n      registry = parts[0]\n      remainder = parts.slice(1).join('/')\n    }\n\n    const namespaceParts = remainder.split('/')\n    const repo = namespaceParts.pop()!\n    const namespace = namespaceParts.join('/')\n\n    return {\n      registry,\n      namespace,\n      repo,\n      tag,\n      fullName: remainder,\n    }\n  }\n\n  /**\n   * Get an anonymous auth token for the given registry and repository.\n   * NOTE: This could be expanded in the future to support private repo authentication\n   */\n  private async getToken(registry: string, fullName: string): Promise<string> {\n    const cacheKey = `${registry}/${fullName}`\n    const cached = this.tokenCache.get(cacheKey)\n    if (cached && cached.expiresAt > Date.now()) {\n      return cached.token\n    }\n\n    let tokenUrl: string\n    if (registry === 'registry-1.docker.io') {\n      tokenUrl = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${fullName}:pull`\n    } else if (registry === 'ghcr.io') {\n      tokenUrl = `https://ghcr.io/token?service=ghcr.io&scope=repository:${fullName}:pull`\n    } else {\n      // For other registries, try the standard v2 token endpoint\n      tokenUrl = `https://${registry}/token?service=${registry}&scope=repository:${fullName}:pull`\n    }\n\n    const response = await this.fetchWithRetry(tokenUrl)\n    if (!response.ok) {\n      throw new Error(`Failed to get auth token from ${registry}: ${response.status}`)\n    }\n\n    const data = (await response.json()) as { token?: string; access_token?: string }\n    const token = data.token || data.access_token || ''\n\n    if (!token) {\n      throw new Error(`No token returned from ${registry}`)\n    }\n\n    // Cache for 5 minutes (tokens usually last longer, but be conservative)\n    this.tokenCache.set(cacheKey, {\n      token,\n      expiresAt: Date.now() + 5 * 60 * 1000,\n    })\n\n    return token\n  }\n\n  /**\n   * List all tags for a given image from the registry.\n   */\n  async listTags(parsed: ParsedImageReference): Promise<string[]> {\n    const token = await this.getToken(parsed.registry, parsed.fullName)\n    const allTags: string[] = []\n    let url = `https://${parsed.registry}/v2/${parsed.fullName}/tags/list?n=1000`\n\n    while (url) {\n      const response = await this.fetchWithRetry(url, {\n        headers: { Authorization: `Bearer ${token}` },\n      })\n\n      if (!response.ok) {\n        throw new Error(`Failed to list tags for ${parsed.fullName}: ${response.status}`)\n      }\n\n      const data = (await response.json()) as { tags?: string[] }\n      if (data.tags) {\n        allTags.push(...data.tags)\n      }\n\n      // Handle pagination via Link header\n      const linkHeader = response.headers.get('link')\n      if (linkHeader) {\n        const match = linkHeader.match(/<([^>]+)>;\\s*rel=\"next\"/)\n        url = match ? match[1] : ''\n      } else {\n        url = ''\n      }\n    }\n\n    return allTags\n  }\n\n  /**\n   * Check if a specific tag supports the given architecture by fetching its manifest.\n   */\n  async checkArchSupport(parsed: ParsedImageReference, tag: string, hostArch: string): Promise<boolean> {\n    try {\n      const token = await this.getToken(parsed.registry, parsed.fullName)\n      const url = `https://${parsed.registry}/v2/${parsed.fullName}/manifests/${tag}`\n\n      const response = await this.fetchWithRetry(url, {\n        headers: {\n          Authorization: `Bearer ${token}`,\n          Accept: [\n            'application/vnd.oci.image.index.v1+json',\n            'application/vnd.docker.distribution.manifest.list.v2+json',\n            'application/vnd.oci.image.manifest.v1+json',\n            'application/vnd.docker.distribution.manifest.v2+json',\n          ].join(', '),\n        },\n      })\n\n      if (!response.ok) return true // If we can't check, assume it's compatible\n\n      const manifest = (await response.json()) as {\n        mediaType?: string\n        manifests?: Array<{ platform?: { architecture?: string } }>\n      }\n      const mediaType = manifest.mediaType || response.headers.get('content-type') || ''\n\n      // Manifest list — check if any platform matches\n      if (\n        mediaType.includes('manifest.list') ||\n        mediaType.includes('image.index') ||\n        manifest.manifests\n      ) {\n        const manifests = manifest.manifests || []\n        return manifests.some(\n          (m: any) => m.platform && m.platform.architecture === hostArch\n        )\n      }\n\n      // Single manifest — assume compatible (can't easily determine arch without fetching config blob)\n      return true\n    } catch (error) {\n      logger.warn(`[ContainerRegistryService] Error checking arch for ${tag}: ${error.message}`)\n      return true // Assume compatible on error\n    }\n  }\n\n  /**\n   * Extract the source repository URL from an image's OCI labels.\n   * Uses the standardized `org.opencontainers.image.source` label.\n   * Result is cached per image (not per tag).\n   */\n  async getSourceUrl(parsed: ParsedImageReference): Promise<string | null> {\n    const cacheKey = `${parsed.registry}/${parsed.fullName}`\n    if (this.sourceUrlCache.has(cacheKey)) {\n      return this.sourceUrlCache.get(cacheKey)!\n    }\n\n    try {\n      const token = await this.getToken(parsed.registry, parsed.fullName)\n\n      // First get the manifest to find the config blob digest\n      const manifestUrl = `https://${parsed.registry}/v2/${parsed.fullName}/manifests/${parsed.tag}`\n      const manifestRes = await this.fetchWithRetry(manifestUrl, {\n        headers: {\n          Authorization: `Bearer ${token}`,\n          Accept: [\n            'application/vnd.oci.image.manifest.v1+json',\n            'application/vnd.docker.distribution.manifest.v2+json',\n            'application/vnd.oci.image.index.v1+json',\n            'application/vnd.docker.distribution.manifest.list.v2+json',\n          ].join(', '),\n        },\n      })\n\n      if (!manifestRes.ok) {\n        this.sourceUrlCache.set(cacheKey, null)\n        return null\n      }\n\n      const manifest = (await manifestRes.json()) as {\n        config?: { digest?: string }\n        manifests?: Array<{ digest?: string; mediaType?: string; platform?: { architecture?: string } }>\n      }\n\n      // If this is a manifest list, pick the first manifest to get the config\n      let configDigest = manifest.config?.digest\n      if (!configDigest && manifest.manifests?.length) {\n        const firstManifest = manifest.manifests[0]\n        if (firstManifest.digest) {\n          const childRes = await this.fetchWithRetry(\n            `https://${parsed.registry}/v2/${parsed.fullName}/manifests/${firstManifest.digest}`,\n            {\n              headers: {\n                Authorization: `Bearer ${token}`,\n                Accept: 'application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json',\n              },\n            }\n          )\n          if (childRes.ok) {\n            const childManifest = (await childRes.json()) as { config?: { digest?: string } }\n            configDigest = childManifest.config?.digest\n          }\n        }\n      }\n\n      if (!configDigest) {\n        this.sourceUrlCache.set(cacheKey, null)\n        return null\n      }\n\n      // Fetch the config blob to read labels\n      const blobUrl = `https://${parsed.registry}/v2/${parsed.fullName}/blobs/${configDigest}`\n      const blobRes = await this.fetchWithRetry(blobUrl, {\n        headers: { Authorization: `Bearer ${token}` },\n      })\n\n      if (!blobRes.ok) {\n        this.sourceUrlCache.set(cacheKey, null)\n        return null\n      }\n\n      const config = (await blobRes.json()) as {\n        config?: { Labels?: Record<string, string> }\n      }\n\n      const sourceUrl = config.config?.Labels?.['org.opencontainers.image.source'] || null\n      this.sourceUrlCache.set(cacheKey, sourceUrl)\n      return sourceUrl\n    } catch (error) {\n      logger.warn(`[ContainerRegistryService] Failed to get source URL for ${cacheKey}: ${error.message}`)\n      this.sourceUrlCache.set(cacheKey, null)\n      return null\n    }\n  }\n\n  /**\n   * Detect whether a GitHub/GitLab repo uses a 'v' prefix on release tags.\n   * Probes the GitHub API with the current tag to determine the convention,\n   * then caches the result per source URL.\n   */\n  async detectReleaseTagPrefix(sourceUrl: string, sampleTag: string): Promise<string> {\n    if (this.releaseTagPrefixCache.has(sourceUrl)) {\n      return this.releaseTagPrefixCache.get(sourceUrl)!\n    }\n\n    try {\n      const url = new URL(sourceUrl)\n      if (url.hostname !== 'github.com') {\n        this.releaseTagPrefixCache.set(sourceUrl, '')\n        return ''\n      }\n\n      const cleanPath = url.pathname.replace(/\\.git$/, '').replace(/\\/$/, '')\n      const strippedTag = sampleTag.replace(/^v/, '')\n      const vTag = `v${strippedTag}`\n\n      // Try both variants against GitHub's API — the one that 200s tells us the convention\n      // Try v-prefixed first since it's more common\n      const vRes = await this.fetchWithRetry(\n        `https://api.github.com/repos${cleanPath}/releases/tags/${vTag}`,\n        { headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'ProjectNomad' } },\n        1\n      )\n      if (vRes.ok) {\n        this.releaseTagPrefixCache.set(sourceUrl, 'v')\n        return 'v'\n      }\n\n      const plainRes = await this.fetchWithRetry(\n        `https://api.github.com/repos${cleanPath}/releases/tags/${strippedTag}`,\n        { headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'ProjectNomad' } },\n        1\n      )\n      if (plainRes.ok) {\n        this.releaseTagPrefixCache.set(sourceUrl, '')\n        return ''\n      }\n    } catch {\n      // On error, fall through to default\n    }\n\n    // Default: no prefix modification\n    this.releaseTagPrefixCache.set(sourceUrl, '')\n    return ''\n  }\n\n  /**\n   * Build a release URL for a specific tag given a source repository URL and\n   * the detected release tag prefix convention.\n   * Supports GitHub and GitLab URL patterns.\n   */\n  buildReleaseUrl(sourceUrl: string, tag: string, releaseTagPrefix: string): string | undefined {\n    try {\n      const url = new URL(sourceUrl)\n      if (url.hostname === 'github.com' || url.hostname.includes('gitlab')) {\n        const cleanPath = url.pathname.replace(/\\.git$/, '').replace(/\\/$/, '')\n        const strippedTag = tag.replace(/^v/, '')\n        const releaseTag = releaseTagPrefix ? `${releaseTagPrefix}${strippedTag}` : strippedTag\n        return `${url.origin}${cleanPath}/releases/tag/${releaseTag}`\n      }\n    } catch {\n      // Invalid URL, skip\n    }\n    return undefined\n  }\n\n  /**\n   * Filter and sort tags to find compatible updates for a service.\n   */\n  filterCompatibleUpdates(\n    tags: string[],\n    currentTag: string,\n    majorVersion: number\n  ): string[] {\n    return tags\n      .filter((tag) => {\n        // Must match semver pattern\n        if (!SEMVER_TAG_PATTERN.test(tag)) return false\n\n        // Reject known non-version tags\n        if (REJECTED_TAGS.has(tag.toLowerCase())) return false\n\n        // Reject platform suffixes\n        if (PLATFORM_SUFFIXES.some((suffix) => tag.toLowerCase().endsWith(suffix))) return false\n\n        // Must be same major version\n        if (parseMajorVersion(tag) !== majorVersion) return false\n\n        // Must be newer than current\n        return isNewerVersion(tag, currentTag)\n      })\n      .sort((a, b) => (isNewerVersion(a, b) ? -1 : 1)) // Newest first\n  }\n\n  /**\n   * High-level method to get available updates for a service.\n   * Returns a sorted list of compatible newer versions (newest first).\n   */\n  async getAvailableUpdates(\n    containerImage: string,\n    hostArch: string,\n    fallbackSourceRepo?: string | null\n  ): Promise<AvailableUpdate[]> {\n    const parsed = this.parseImageReference(containerImage)\n    const currentTag = parsed.tag\n\n    if (currentTag === 'latest') {\n      logger.warn(\n        `[ContainerRegistryService] Cannot check updates for ${containerImage} — using :latest tag`\n      )\n      return []\n    }\n\n    const majorVersion = parseMajorVersion(currentTag)\n\n    // Fetch tags and source URL in parallel\n    const [tags, ociSourceUrl] = await Promise.all([\n      this.listTags(parsed),\n      this.getSourceUrl(parsed),\n    ])\n\n    // OCI label takes precedence, fall back to DB-stored source_repo\n    const sourceUrl = ociSourceUrl || fallbackSourceRepo || null\n\n    const compatible = this.filterCompatibleUpdates(tags, currentTag, majorVersion)\n\n    // Detect release tag prefix convention (e.g. 'v' vs no prefix) if we have a source URL\n    let releaseTagPrefix = ''\n    if (sourceUrl) {\n      releaseTagPrefix = await this.detectReleaseTagPrefix(sourceUrl, currentTag)\n    }\n\n    // Check architecture support for the top candidates (limit checks to save API calls)\n    const maxArchChecks = 10\n    const results: AvailableUpdate[] = []\n\n    for (const tag of compatible.slice(0, maxArchChecks)) {\n      const supported = await this.checkArchSupport(parsed, tag, hostArch)\n      if (supported) {\n        results.push({\n          tag,\n          isLatest: results.length === 0,\n          releaseUrl: sourceUrl ? this.buildReleaseUrl(sourceUrl, tag, releaseTagPrefix) : undefined,\n        })\n      }\n    }\n\n    // For remaining tags (beyond arch check limit), include them but mark as not latest\n    for (const tag of compatible.slice(maxArchChecks)) {\n      results.push({\n        tag,\n        isLatest: false,\n        releaseUrl: sourceUrl ? this.buildReleaseUrl(sourceUrl, tag, releaseTagPrefix) : undefined,\n      })\n    }\n\n    return results\n  }\n\n  /**\n   * Fetch with retry and exponential backoff for rate limiting.\n   */\n  private async fetchWithRetry(\n    url: string,\n    init?: RequestInit,\n    maxRetries = 3\n  ): Promise<Response> {\n    for (let attempt = 0; attempt <= maxRetries; attempt++) {\n      const response = await fetch(url, init)\n\n      if (response.status === 429 && attempt < maxRetries) {\n        const retryAfter = response.headers.get('retry-after')\n        const delay = retryAfter\n          ? parseInt(retryAfter, 10) * 1000\n          : Math.pow(2, attempt) * 1000\n        logger.warn(\n          `[ContainerRegistryService] Rate limited on ${url}, retrying in ${delay}ms`\n        )\n        await new Promise((resolve) => setTimeout(resolve, delay))\n        continue\n      }\n\n      return response\n    }\n\n    throw new Error(`Failed to fetch ${url} after ${maxRetries} retries`)\n  }\n}\n"
  },
  {
    "path": "admin/app/services/docker_service.ts",
    "content": "import Service from '#models/service'\nimport Docker from 'dockerode'\nimport logger from '@adonisjs/core/services/logger'\nimport { inject } from '@adonisjs/core'\nimport transmit from '@adonisjs/transmit/services/main'\nimport { doResumableDownloadWithRetry } from '../utils/downloads.js'\nimport { join } from 'path'\nimport { ZIM_STORAGE_PATH } from '../utils/fs.js'\nimport { SERVICE_NAMES } from '../../constants/service_names.js'\nimport { exec } from 'child_process'\nimport { promisify } from 'util'\n// import { readdir } from 'fs/promises'\nimport KVStore from '#models/kv_store'\nimport { BROADCAST_CHANNELS } from '../../constants/broadcast.js'\n\n@inject()\nexport class DockerService {\n  public docker: Docker\n  private activeInstallations: Set<string> = new Set()\n  public static NOMAD_NETWORK = 'project-nomad_default'\n\n  constructor() {\n    // Support both Linux (production) and Windows (development with Docker Desktop)\n    const isWindows = process.platform === 'win32'\n    if (isWindows) {\n      // Windows Docker Desktop uses named pipe\n      this.docker = new Docker({ socketPath: '//./pipe/docker_engine' })\n    } else {\n      // Linux uses Unix socket\n      this.docker = new Docker({ socketPath: '/var/run/docker.sock' })\n    }\n  }\n\n  async affectContainer(\n    serviceName: string,\n    action: 'start' | 'stop' | 'restart'\n  ): Promise<{ success: boolean; message: string }> {\n    try {\n      const service = await Service.query().where('service_name', serviceName).first()\n      if (!service || !service.installed) {\n        return {\n          success: false,\n          message: `Service ${serviceName} not found or not installed`,\n        }\n      }\n\n      const containers = await this.docker.listContainers({ all: true })\n      const container = containers.find((c) => c.Names.includes(`/${serviceName}`))\n      if (!container) {\n        return {\n          success: false,\n          message: `Container for service ${serviceName} not found`,\n        }\n      }\n\n      const dockerContainer = this.docker.getContainer(container.Id)\n      if (action === 'stop') {\n        await dockerContainer.stop()\n        return {\n          success: true,\n          message: `Service ${serviceName} stopped successfully`,\n        }\n      }\n\n      if (action === 'restart') {\n        await dockerContainer.restart()\n\n        return {\n          success: true,\n          message: `Service ${serviceName} restarted successfully`,\n        }\n      }\n\n      if (action === 'start') {\n        if (container.State === 'running') {\n          return {\n            success: true,\n            message: `Service ${serviceName} is already running`,\n          }\n        }\n\n        await dockerContainer.start()\n\n        return {\n          success: true,\n          message: `Service ${serviceName} started successfully`,\n        }\n      }\n\n      return {\n        success: false,\n        message: `Invalid action: ${action}. Use 'start', 'stop', or 'restart'.`,\n      }\n    } catch (error) {\n      logger.error(`Error starting service ${serviceName}: ${error.message}`)\n      return {\n        success: false,\n        message: `Failed to start service ${serviceName}: ${error.message}`,\n      }\n    }\n  }\n\n  /**\n   * Fetches the status of all Docker containers related to Nomad services. (those prefixed with 'nomad_')\n   */\n  async getServicesStatus(): Promise<\n    {\n      service_name: string\n      status: string\n    }[]\n  > {\n    try {\n      const containers = await this.docker.listContainers({ all: true })\n      const containerMap = new Map<string, Docker.ContainerInfo>()\n      containers.forEach((container) => {\n        const name = container.Names[0]?.replace('/', '')\n        if (name && name.startsWith('nomad_')) {\n          containerMap.set(name, container)\n        }\n      })\n\n      return Array.from(containerMap.entries()).map(([name, container]) => ({\n        service_name: name,\n        status: container.State,\n      }))\n    } catch (error) {\n      logger.error(`Error fetching services status: ${error.message}`)\n      return []\n    }\n  }\n\n  /**\n   * Get the URL to access a service based on its configuration.\n   * Attempts to return a docker-internal URL using the service name and exposed port.\n   * @param serviceName - The name of the service to get the URL for.\n   * @returns - The URL as a string, or null if it cannot be determined.\n   */\n  async getServiceURL(serviceName: string): Promise<string | null> {\n    if (!serviceName || serviceName.trim() === '') {\n      return null\n    }\n\n    const service = await Service.query()\n      .where('service_name', serviceName)\n      .andWhere('installed', true)\n      .first()\n\n    if (!service) {\n      return null\n    }\n\n    const hostname = process.env.NODE_ENV === 'production' ? serviceName : 'localhost'\n\n    // First, check if ui_location is set and is a valid port number\n    if (service.ui_location && parseInt(service.ui_location, 10)) {\n      return `http://${hostname}:${service.ui_location}`\n    }\n\n    // Next, try to extract a host port from container_config\n    const parsedConfig = this._parseContainerConfig(service.container_config)\n    if (parsedConfig?.HostConfig?.PortBindings) {\n      const portBindings = parsedConfig.HostConfig.PortBindings\n      const hostPorts = Object.values(portBindings)\n      if (!hostPorts || !Array.isArray(hostPorts) || hostPorts.length === 0) {\n        return null\n      }\n\n      const hostPortsArray = hostPorts.flat() as { HostPort: string }[]\n      const hostPortsStrings = hostPortsArray.map((binding) => binding.HostPort)\n      if (hostPortsStrings.length > 0) {\n        return `http://${hostname}:${hostPortsStrings[0]}`\n      }\n    }\n\n    // Otherwise, return null if we can't determine a URL\n    return null\n  }\n\n  async createContainerPreflight(\n    serviceName: string\n  ): Promise<{ success: boolean; message: string }> {\n    const service = await Service.query().where('service_name', serviceName).first()\n    if (!service) {\n      return {\n        success: false,\n        message: `Service ${serviceName} not found`,\n      }\n    }\n\n    if (service.installed) {\n      return {\n        success: false,\n        message: `Service ${serviceName} is already installed`,\n      }\n    }\n\n    // Check if installation is already in progress (database-level)\n    if (service.installation_status === 'installing') {\n      return {\n        success: false,\n        message: `Service ${serviceName} installation is already in progress`,\n      }\n    }\n\n    // Double-check with in-memory tracking (race condition protection)\n    if (this.activeInstallations.has(serviceName)) {\n      return {\n        success: false,\n        message: `Service ${serviceName} installation is already in progress`,\n      }\n    }\n\n    // Mark installation as in progress\n    this.activeInstallations.add(serviceName)\n    service.installation_status = 'installing'\n    await service.save()\n\n    // Check if a service wasn't marked as installed but has an existing container\n    // This can happen if the service was created but not properly installed\n    // or if the container was removed manually without updating the service status.\n    // if (await this._checkIfServiceContainerExists(serviceName)) {\n    //   const removeResult = await this._removeServiceContainer(serviceName);\n    //   if (!removeResult.success) {\n    //     return {\n    //       success: false,\n    //       message: `Failed to remove existing container for service ${serviceName}: ${removeResult.message}`,\n    //     };\n    //   }\n    // }\n\n    const containerConfig = this._parseContainerConfig(service.container_config)\n\n    // Execute installation asynchronously and handle cleanup\n    this._createContainer(service, containerConfig).catch(async (error) => {\n      logger.error(`Installation failed for ${serviceName}: ${error.message}`)\n      await this._cleanupFailedInstallation(serviceName)\n    })\n\n    return {\n      success: true,\n      message: `Service ${serviceName} installation initiated successfully. You can receive updates via server-sent events.`,\n    }\n  }\n\n  /**\n   * Force reinstall a service by stopping, removing, and recreating its container.\n   * This method will also clear any associated volumes/data.\n   * Handles edge cases gracefully (e.g., container not running, container not found).\n   */\n  async forceReinstall(serviceName: string): Promise<{ success: boolean; message: string }> {\n    try {\n      const service = await Service.query().where('service_name', serviceName).first()\n      if (!service) {\n        return {\n          success: false,\n          message: `Service ${serviceName} not found`,\n        }\n      }\n\n      // Check if installation is already in progress\n      if (this.activeInstallations.has(serviceName)) {\n        return {\n          success: false,\n          message: `Service ${serviceName} installation is already in progress`,\n        }\n      }\n\n      // Mark as installing to prevent concurrent operations\n      this.activeInstallations.add(serviceName)\n      service.installation_status = 'installing'\n      await service.save()\n\n      this._broadcast(\n        serviceName,\n        'reinstall-starting',\n        `Starting force reinstall for ${serviceName}...`\n      )\n\n      // Step 1: Try to stop and remove the container if it exists\n      try {\n        const containers = await this.docker.listContainers({ all: true })\n        const container = containers.find((c) => c.Names.includes(`/${serviceName}`))\n\n        if (container) {\n          const dockerContainer = this.docker.getContainer(container.Id)\n\n          // Only try to stop if it's running\n          if (container.State === 'running') {\n            this._broadcast(serviceName, 'stopping', `Stopping container...`)\n            await dockerContainer.stop({ t: 10 }).catch((error) => {\n              // If already stopped, continue\n              if (!error.message.includes('already stopped')) {\n                logger.warn(`Error stopping container: ${error.message}`)\n              }\n            })\n          }\n\n          // Step 2: Remove the container\n          this._broadcast(serviceName, 'removing', `Removing container...`)\n          await dockerContainer.remove({ force: true }).catch((error) => {\n            logger.warn(`Error removing container: ${error.message}`)\n          })\n        } else {\n          this._broadcast(\n            serviceName,\n            'no-container',\n            `No existing container found, proceeding with installation...`\n          )\n        }\n      } catch (error) {\n        logger.warn(`Error during container cleanup: ${error.message}`)\n        this._broadcast(serviceName, 'cleanup-warning', `Warning during cleanup: ${error.message}`)\n      }\n\n      // Step 3: Clear volumes/data if needed\n      try {\n        this._broadcast(serviceName, 'clearing-volumes', `Checking for volumes to clear...`)\n        const volumes = await this.docker.listVolumes()\n        const serviceVolumes =\n          volumes.Volumes?.filter(\n            (v) => v.Name.includes(serviceName) || v.Labels?.service === serviceName\n          ) || []\n\n        for (const vol of serviceVolumes) {\n          try {\n            const volume = this.docker.getVolume(vol.Name)\n            await volume.remove({ force: true })\n            this._broadcast(serviceName, 'volume-removed', `Removed volume: ${vol.Name}`)\n          } catch (error) {\n            logger.warn(`Failed to remove volume ${vol.Name}: ${error.message}`)\n          }\n        }\n\n        if (serviceVolumes.length === 0) {\n          this._broadcast(serviceName, 'no-volumes', `No volumes found to clear`)\n        }\n      } catch (error) {\n        logger.warn(`Error during volume cleanup: ${error.message}`)\n        this._broadcast(\n          serviceName,\n          'volume-cleanup-warning',\n          `Warning during volume cleanup: ${error.message}`\n        )\n      }\n\n      // Step 4: Mark service as uninstalled\n      service.installed = false\n      service.installation_status = 'installing'\n      await service.save()\n\n      // Step 5: Recreate the container\n      this._broadcast(serviceName, 'recreating', `Recreating container...`)\n      const containerConfig = this._parseContainerConfig(service.container_config)\n\n      // Execute installation asynchronously and handle cleanup\n      this._createContainer(service, containerConfig).catch(async (error) => {\n        logger.error(`Reinstallation failed for ${serviceName}: ${error.message}`)\n        await this._cleanupFailedInstallation(serviceName)\n      })\n\n      return {\n        success: true,\n        message: `Service ${serviceName} force reinstall initiated successfully. You can receive updates via server-sent events.`,\n      }\n    } catch (error) {\n      logger.error(`Force reinstall failed for ${serviceName}: ${error.message}`)\n      await this._cleanupFailedInstallation(serviceName)\n      return {\n        success: false,\n        message: `Failed to force reinstall service ${serviceName}: ${error.message}`,\n      }\n    }\n  }\n\n  /**\n   * Handles the long-running process of creating a Docker container for a service.\n   * NOTE: This method should not be called directly. Instead, use `createContainerPreflight` to check prerequisites first\n   * This method will also transmit server-sent events to the client to notify of progress.\n   * @param serviceName\n   * @returns\n   */\n  async _createContainer(\n    service: Service & { dependencies?: Service[] },\n    containerConfig: any\n  ): Promise<void> {\n    try {\n      this._broadcast(service.service_name, 'initializing', '')\n\n      let dependencies = []\n      if (service.depends_on) {\n        const dependency = await Service.query().where('service_name', service.depends_on).first()\n        if (dependency) {\n          dependencies.push(dependency)\n        }\n      }\n\n      // First, check if the service has any dependencies that need to be installed first\n      if (dependencies && dependencies.length > 0) {\n        this._broadcast(\n          service.service_name,\n          'checking-dependencies',\n          `Checking dependencies for service ${service.service_name}...`\n        )\n        for (const dependency of dependencies) {\n          if (!dependency.installed) {\n            this._broadcast(\n              service.service_name,\n              'dependency-not-installed',\n              `Dependency service ${dependency.service_name} is not installed. Installing it first...`\n            )\n            await this._createContainer(\n              dependency,\n              this._parseContainerConfig(dependency.container_config)\n            )\n          } else {\n            this._broadcast(\n              service.service_name,\n              'dependency-installed',\n              `Dependency service ${dependency.service_name} is already installed.`\n            )\n          }\n        }\n      }\n\n      const imageExists = await this._checkImageExists(service.container_image)\n      if (imageExists) {\n        this._broadcast(\n          service.service_name,\n          'image-exists',\n          `Docker image ${service.container_image} already exists locally. Skipping pull...`\n        )\n      } else {\n        // Start pulling the Docker image and wait for it to complete\n        const pullStream = await this.docker.pull(service.container_image)\n        this._broadcast(\n          service.service_name,\n          'pulling',\n          `Pulling Docker image ${service.container_image}...`\n        )\n        await new Promise((res) => this.docker.modem.followProgress(pullStream, res))\n      }\n\n      if (service.service_name === SERVICE_NAMES.KIWIX) {\n        await this._runPreinstallActions__KiwixServe()\n        this._broadcast(\n          service.service_name,\n          'preinstall-complete',\n          `Pre-install actions for Kiwix Serve completed successfully.`\n        )\n      }\n\n      // GPU-aware configuration for Ollama\n      let finalImage = service.container_image\n      let gpuHostConfig = containerConfig?.HostConfig || {}\n\n      if (service.service_name === SERVICE_NAMES.OLLAMA) {\n        const gpuResult = await this._detectGPUType()\n\n        if (gpuResult.type === 'nvidia') {\n          this._broadcast(\n            service.service_name,\n            'gpu-config',\n            `NVIDIA container runtime detected. Configuring container with GPU support...`\n          )\n\n          // Add GPU support for NVIDIA\n          gpuHostConfig = {\n            ...gpuHostConfig,\n            DeviceRequests: [\n              {\n                Driver: 'nvidia',\n                Count: -1, // -1 means all GPUs\n                Capabilities: [['gpu']],\n              },\n            ],\n          }\n        } else if (gpuResult.type === 'amd') {\n          this._broadcast(\n            service.service_name,\n            'gpu-config',\n            `AMD GPU detected. ROCm GPU acceleration is not yet supported in this version — proceeding with CPU-only configuration. GPU support for AMD will be available in a future update.`\n          )\n          logger.warn('[DockerService] AMD GPU detected but ROCm support is not yet enabled. Using CPU-only configuration.')\n          // TODO: Re-enable AMD GPU support once ROCm image and device discovery are validated.\n          // When re-enabling:\n          //   1. Switch image to 'ollama/ollama:rocm'\n          //   2. Restore _discoverAMDDevices() to map /dev/kfd and /dev/dri/* into the container\n        } else if (gpuResult.toolkitMissing) {\n          this._broadcast(\n            service.service_name,\n            'gpu-config',\n            `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`\n          )\n        } else {\n          this._broadcast(\n            service.service_name,\n            'gpu-config',\n            `No GPU detected. Using CPU-only configuration...`\n          )\n        }\n      }\n\n      this._broadcast(\n        service.service_name,\n        'creating',\n        `Creating Docker container for service ${service.service_name}...`\n      )\n      const container = await this.docker.createContainer({\n        Image: finalImage,\n        name: service.service_name,\n        ...(containerConfig?.User && { User: containerConfig.User }),\n        HostConfig: gpuHostConfig,\n        ...(containerConfig?.WorkingDir && { WorkingDir: containerConfig.WorkingDir }),\n        ...(containerConfig?.ExposedPorts && { ExposedPorts: containerConfig.ExposedPorts }),\n        ...(containerConfig?.Env && { Env: containerConfig.Env }),\n        ...(service.container_command ? { Cmd: service.container_command.split(' ') } : {}),\n        // Ensure container is attached to the Nomad docker network in production\n        ...(process.env.NODE_ENV === 'production' && {\n          NetworkingConfig: {\n            EndpointsConfig: {\n              [DockerService.NOMAD_NETWORK]: {},\n            },\n          },\n        }),\n      })\n\n      this._broadcast(\n        service.service_name,\n        'starting',\n        `Starting Docker container for service ${service.service_name}...`\n      )\n      await container.start()\n\n      this._broadcast(\n        service.service_name,\n        'finalizing',\n        `Finalizing installation of service ${service.service_name}...`\n      )\n      service.installed = true\n      service.installation_status = 'idle'\n      await service.save()\n\n      // Remove from active installs tracking\n      this.activeInstallations.delete(service.service_name)\n\n      // If Ollama was just installed, trigger Nomad docs discovery and embedding\n      if (service.service_name === SERVICE_NAMES.OLLAMA) {\n        logger.info('[DockerService] Ollama installation complete. Default behavior is to not enable chat suggestions.')\n        await KVStore.setValue('chat.suggestionsEnabled', false)\n\n        logger.info('[DockerService] Ollama installation complete. Triggering Nomad docs discovery...')\n        \n        // Need to use dynamic imports here to avoid circular dependency\n        const ollamaService = new (await import('./ollama_service.js')).OllamaService()\n        const ragService = new (await import('./rag_service.js')).RagService(this, ollamaService)\n\n        ragService.discoverNomadDocs().catch((error) => {\n          logger.error('[DockerService] Failed to discover Nomad docs:', error)\n        })\n      }\n\n      this._broadcast(\n        service.service_name,\n        'completed',\n        `Service ${service.service_name} installation completed successfully.`\n      )\n    } catch (error) {\n      this._broadcast(\n        service.service_name,\n        'error',\n        `Error installing service ${service.service_name}: ${error.message}`\n      )\n      // Mark install as failed and cleanup\n      await this._cleanupFailedInstallation(service.service_name)\n      throw new Error(`Failed to install service ${service.service_name}: ${error.message}`)\n    }\n  }\n\n  async _checkIfServiceContainerExists(serviceName: string): Promise<boolean> {\n    try {\n      const containers = await this.docker.listContainers({ all: true })\n      return containers.some((container) => container.Names.includes(`/${serviceName}`))\n    } catch (error) {\n      logger.error(`Error checking if service container exists: ${error.message}`)\n      return false\n    }\n  }\n\n  async _removeServiceContainer(\n    serviceName: string\n  ): Promise<{ success: boolean; message: string }> {\n    try {\n      const containers = await this.docker.listContainers({ all: true })\n      const container = containers.find((c) => c.Names.includes(`/${serviceName}`))\n      if (!container) {\n        return { success: false, message: `Container for service ${serviceName} not found` }\n      }\n\n      const dockerContainer = this.docker.getContainer(container.Id)\n      await dockerContainer.remove({ force: true })\n\n      return { success: true, message: `Service ${serviceName} container removed successfully` }\n    } catch (error) {\n      logger.error(`Error removing service container: ${error.message}`)\n      return {\n        success: false,\n        message: `Failed to remove service ${serviceName} container: ${error.message}`,\n      }\n    }\n  }\n\n  private async _runPreinstallActions__KiwixServe(): Promise<void> {\n    /**\n     * At least one .zim file must be available before we can start the kiwix container.\n     * We'll download the lightweight mini Wikipedia Top 100 zim file for this purpose.\n     **/\n    const WIKIPEDIA_ZIM_URL =\n      'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/main/install/wikipedia_en_100_mini_2025-06.zim'\n    const filename = 'wikipedia_en_100_mini_2025-06.zim'\n    const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)\n    logger.info(`[DockerService] Kiwix Serve pre-install: Downloading ZIM file to ${filepath}`)\n\n    this._broadcast(\n      SERVICE_NAMES.KIWIX,\n      'preinstall',\n      `Running pre-install actions for Kiwix Serve...`\n    )\n    this._broadcast(\n      SERVICE_NAMES.KIWIX,\n      'preinstall',\n      `Downloading Wikipedia ZIM file from ${WIKIPEDIA_ZIM_URL}. This may take some time...`\n    )\n\n    try {\n      await doResumableDownloadWithRetry({\n        url: WIKIPEDIA_ZIM_URL,\n        filepath,\n        timeout: 60000,\n        allowedMimeTypes: [\n          'application/x-zim',\n          'application/x-openzim',\n          'application/octet-stream',\n        ],\n      })\n\n      this._broadcast(\n        SERVICE_NAMES.KIWIX,\n        'preinstall',\n        `Downloaded Wikipedia ZIM file to ${filepath}`\n      )\n    } catch (error) {\n      this._broadcast(\n        SERVICE_NAMES.KIWIX,\n        'preinstall-error',\n        `Failed to download Wikipedia ZIM file: ${error.message}`\n      )\n      throw new Error(`Pre-install action failed: ${error.message}`)\n    }\n  }\n\n  private async _cleanupFailedInstallation(serviceName: string): Promise<void> {\n    try {\n      const service = await Service.query().where('service_name', serviceName).first()\n      if (service) {\n        service.installation_status = 'error'\n        await service.save()\n      }\n      this.activeInstallations.delete(serviceName)\n\n      // Ensure any partially created container is removed\n      await this._removeServiceContainer(serviceName)\n\n      logger.info(`[DockerService] Cleaned up failed installation for ${serviceName}`)\n    } catch (error) {\n      logger.error(\n        `[DockerService] Failed to cleanup installation for ${serviceName}: ${error.message}`\n      )\n    }\n  }\n\n  /**\n   * Detect GPU type and toolkit availability.\n   * Primary: Check Docker runtimes via docker.info() (works from inside containers).\n   * Fallback: lspci for host-based installs and AMD detection.\n   */\n  private async _detectGPUType(): Promise<{ type: 'nvidia' | 'amd' | 'none'; toolkitMissing?: boolean }> {\n    try {\n      // Primary: Check Docker daemon for nvidia runtime (works from inside containers)\n      try {\n        const dockerInfo = await this.docker.info()\n        const runtimes = dockerInfo.Runtimes || {}\n        if ('nvidia' in runtimes) {\n          logger.info('[DockerService] NVIDIA container runtime detected via Docker API')\n          await this._persistGPUType('nvidia')\n          return { type: 'nvidia' }\n        }\n      } catch (error) {\n        logger.warn(`[DockerService] Could not query Docker info for GPU runtimes: ${error.message}`)\n      }\n\n      // Fallback: lspci for host-based installs (not available inside Docker)\n      const execAsync = promisify(exec)\n\n      // Check for NVIDIA GPU via lspci\n      try {\n        const { stdout: nvidiaCheck } = await execAsync(\n          'lspci 2>/dev/null | grep -i nvidia || true'\n        )\n        if (nvidiaCheck.trim()) {\n          // GPU hardware found but no nvidia runtime — toolkit not installed\n          logger.warn('[DockerService] NVIDIA GPU detected via lspci but NVIDIA Container Toolkit is not installed')\n          return { type: 'none', toolkitMissing: true }\n        }\n      } catch (error) {\n        // lspci not available (likely inside Docker container), continue\n      }\n\n      // Check for AMD GPU via lspci — restrict to display controller classes to avoid\n      // false positives from AMD CPU host bridges, PCI bridges, and chipset devices.\n      try {\n        const { stdout: amdCheck } = await execAsync(\n          'lspci 2>/dev/null | grep -iE \"VGA|3D controller|Display\" | grep -iE \"amd|radeon\" || true'\n        )\n        if (amdCheck.trim()) {\n          logger.info('[DockerService] AMD GPU detected via lspci')\n          await this._persistGPUType('amd')\n          return { type: 'amd' }\n        }\n      } catch (error) {\n        // lspci not available, continue\n      }\n\n      // Last resort: check if we previously detected a GPU and it's likely still present.\n      // This handles cases where live detection fails transiently (e.g., Docker daemon\n      // hiccup, runtime temporarily unavailable) but the hardware hasn't changed.\n      try {\n        const savedType = await KVStore.getValue('gpu.type')\n        if (savedType === 'nvidia' || savedType === 'amd') {\n          logger.info(`[DockerService] No GPU detected live, but KV store has '${savedType}' from previous detection. Using saved value.`)\n          return { type: savedType as 'nvidia' | 'amd' }\n        }\n      } catch {\n        // KV store not available, continue\n      }\n\n      logger.info('[DockerService] No GPU detected')\n      return { type: 'none' }\n    } catch (error) {\n      logger.warn(`[DockerService] Error detecting GPU type: ${error.message}`)\n      return { type: 'none' }\n    }\n  }\n\n  private async _persistGPUType(type: 'nvidia' | 'amd'): Promise<void> {\n    try {\n      await KVStore.setValue('gpu.type', type)\n      logger.info(`[DockerService] Persisted GPU type '${type}' to KV store`)\n    } catch (error) {\n      logger.warn(`[DockerService] Failed to persist GPU type: ${error.message}`)\n    }\n  }\n\n  /**\n   * Discover AMD GPU DRI devices dynamically.\n   * Returns an array of device configurations for Docker.\n   */\n  // private async _discoverAMDDevices(): Promise<\n  //   Array<{ PathOnHost: string; PathInContainer: string; CgroupPermissions: string }>\n  // > {\n  //   try {\n  //     const devices: Array<{\n  //       PathOnHost: string\n  //       PathInContainer: string\n  //       CgroupPermissions: string\n  //     }> = []\n\n  //     // Always add /dev/kfd (Kernel Fusion Driver)\n  //     devices.push({\n  //       PathOnHost: '/dev/kfd',\n  //       PathInContainer: '/dev/kfd',\n  //       CgroupPermissions: 'rwm',\n  //     })\n\n  //     // Discover DRI devices in /dev/dri/\n  //     try {\n  //       const driDevices = await readdir('/dev/dri')\n  //       for (const device of driDevices) {\n  //         const devicePath = `/dev/dri/${device}`\n  //         devices.push({\n  //           PathOnHost: devicePath,\n  //           PathInContainer: devicePath,\n  //           CgroupPermissions: 'rwm',\n  //         })\n  //       }\n  //       logger.info(\n  //         `[DockerService] Discovered ${driDevices.length} DRI devices: ${driDevices.join(', ')}`\n  //       )\n  //     } catch (error) {\n  //       logger.warn(`[DockerService] Could not read /dev/dri directory: ${error.message}`)\n  //       // Fallback to common device names if directory read fails\n  //       const fallbackDevices = ['card0', 'renderD128']\n  //       for (const device of fallbackDevices) {\n  //         devices.push({\n  //           PathOnHost: `/dev/dri/${device}`,\n  //           PathInContainer: `/dev/dri/${device}`,\n  //           CgroupPermissions: 'rwm',\n  //         })\n  //       }\n  //       logger.info(`[DockerService] Using fallback DRI devices: ${fallbackDevices.join(', ')}`)\n  //     }\n\n  //     return devices\n  //   } catch (error) {\n  //     logger.error(`[DockerService] Error discovering AMD devices: ${error.message}`)\n  //     return []\n  //   }\n  // }\n\n  /**\n   * Update a service container to a new image version while preserving volumes and data.\n   * Includes automatic rollback if the new container fails health checks.\n   */\n  async updateContainer(\n    serviceName: string,\n    targetVersion: string\n  ): Promise<{ success: boolean; message: string }> {\n    try {\n      const service = await Service.query().where('service_name', serviceName).first()\n      if (!service) {\n        return { success: false, message: `Service ${serviceName} not found` }\n      }\n      if (!service.installed) {\n        return { success: false, message: `Service ${serviceName} is not installed` }\n      }\n      if (this.activeInstallations.has(serviceName)) {\n        return { success: false, message: `Service ${serviceName} already has an operation in progress` }\n      }\n\n      this.activeInstallations.add(serviceName)\n\n      // Compute new image string\n      const currentImage = service.container_image\n      const imageBase = currentImage.includes(':')\n        ? currentImage.substring(0, currentImage.lastIndexOf(':'))\n        : currentImage\n      const newImage = `${imageBase}:${targetVersion}`\n\n      // Step 1: Pull new image\n      this._broadcast(serviceName, 'update-pulling', `Pulling image ${newImage}...`)\n      const pullStream = await this.docker.pull(newImage)\n      await new Promise((res) => this.docker.modem.followProgress(pullStream, res))\n\n      // Step 2: Find and stop existing container\n      this._broadcast(serviceName, 'update-stopping', `Stopping current container...`)\n      const containers = await this.docker.listContainers({ all: true })\n      const existingContainer = containers.find((c) => c.Names.includes(`/${serviceName}`))\n\n      if (!existingContainer) {\n        this.activeInstallations.delete(serviceName)\n        return { success: false, message: `Container for ${serviceName} not found` }\n      }\n\n      const oldContainer = this.docker.getContainer(existingContainer.Id)\n\n      // Inspect to capture full config before stopping\n      const inspectData = await oldContainer.inspect()\n\n      if (existingContainer.State === 'running') {\n        await oldContainer.stop({ t: 15 })\n      }\n\n      // Step 3: Rename old container as safety net\n      const oldName = `${serviceName}_old`\n      await oldContainer.rename({ name: oldName })\n\n      // Step 4: Create new container with inspected config + new image\n      this._broadcast(serviceName, 'update-creating', `Creating updated container...`)\n\n      const hostConfig = inspectData.HostConfig || {}\n\n      // Re-run GPU detection for Ollama so updates always reflect the current GPU environment.\n      // This handles cases where the NVIDIA Container Toolkit was installed after the initial\n      // Ollama setup, and ensures DeviceRequests are always built fresh rather than relying on\n      // round-tripping the Docker inspect format back into the create API.\n      let updatedDeviceRequests: any[] | undefined = undefined\n      if (serviceName === SERVICE_NAMES.OLLAMA) {\n        const gpuResult = await this._detectGPUType()\n\n        if (gpuResult.type === 'nvidia') {\n          this._broadcast(\n            serviceName,\n            'update-gpu-config',\n            `NVIDIA container runtime detected. Configuring updated container with GPU support...`\n          )\n          updatedDeviceRequests = [\n            {\n              Driver: 'nvidia',\n              Count: -1,\n              Capabilities: [['gpu']],\n            },\n          ]\n        } else if (gpuResult.type === 'amd') {\n          this._broadcast(\n            serviceName,\n            'update-gpu-config',\n            `AMD GPU detected. ROCm GPU acceleration is not yet supported — using CPU-only configuration.`\n          )\n        } else if (gpuResult.toolkitMissing) {\n          this._broadcast(\n            serviceName,\n            'update-gpu-config',\n            `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`\n          )\n        } else {\n          this._broadcast(serviceName, 'update-gpu-config', `No GPU detected. Using CPU-only configuration.`)\n        }\n      }\n\n      const newContainerConfig: any = {\n        Image: newImage,\n        name: serviceName,\n        Env: inspectData.Config?.Env || undefined,\n        Cmd: inspectData.Config?.Cmd || undefined,\n        ExposedPorts: inspectData.Config?.ExposedPorts || undefined,\n        WorkingDir: inspectData.Config?.WorkingDir || undefined,\n        User: inspectData.Config?.User || undefined,\n        HostConfig: {\n          Binds: hostConfig.Binds || undefined,\n          PortBindings: hostConfig.PortBindings || undefined,\n          RestartPolicy: hostConfig.RestartPolicy || undefined,\n          DeviceRequests: serviceName === SERVICE_NAMES.OLLAMA ? updatedDeviceRequests : (hostConfig.DeviceRequests || undefined),\n          Devices: hostConfig.Devices || undefined,\n        },\n        NetworkingConfig: inspectData.NetworkSettings?.Networks\n          ? {\n              EndpointsConfig: Object.fromEntries(\n                Object.keys(inspectData.NetworkSettings.Networks).map((net) => [net, {}])\n              ),\n            }\n          : undefined,\n      }\n\n      // Remove undefined values from HostConfig\n      Object.keys(newContainerConfig.HostConfig).forEach((key) => {\n        if (newContainerConfig.HostConfig[key] === undefined) {\n          delete newContainerConfig.HostConfig[key]\n        }\n      })\n\n      let newContainer: any\n      try {\n        newContainer = await this.docker.createContainer(newContainerConfig)\n      } catch (createError) {\n        // Rollback: rename old container back\n        this._broadcast(serviceName, 'update-rollback', `Failed to create new container: ${createError.message}. Rolling back...`)\n        const rollbackContainer = this.docker.getContainer((await this.docker.listContainers({ all: true })).find((c) => c.Names.includes(`/${oldName}`))!.Id)\n        await rollbackContainer.rename({ name: serviceName })\n        await rollbackContainer.start()\n        this.activeInstallations.delete(serviceName)\n        return { success: false, message: `Failed to create updated container: ${createError.message}` }\n      }\n\n      // Step 5: Start new container\n      this._broadcast(serviceName, 'update-starting', `Starting updated container...`)\n      await newContainer.start()\n\n      // Step 6: Health check — verify container stays running for 5 seconds\n      await new Promise((resolve) => setTimeout(resolve, 5000))\n      const newContainerInfo = await newContainer.inspect()\n\n      if (newContainerInfo.State?.Running) {\n        // Healthy — clean up old container\n        try {\n          const oldContainerRef = this.docker.getContainer(\n            (await this.docker.listContainers({ all: true })).find((c) =>\n              c.Names.includes(`/${oldName}`)\n            )?.Id || ''\n          )\n          await oldContainerRef.remove({ force: true })\n        } catch {\n          // Old container may already be gone\n        }\n\n        // Update DB\n        service.container_image = newImage\n        service.available_update_version = null\n        await service.save()\n\n        this.activeInstallations.delete(serviceName)\n        this._broadcast(\n          serviceName,\n          'update-complete',\n          `Successfully updated ${serviceName} to ${targetVersion}`\n        )\n        return { success: true, message: `Service ${serviceName} updated to ${targetVersion}` }\n      } else {\n        // Unhealthy — rollback\n        this._broadcast(\n          serviceName,\n          'update-rollback',\n          `New container failed health check. Rolling back to previous version...`\n        )\n\n        try {\n          await newContainer.stop({ t: 5 }).catch(() => {})\n          await newContainer.remove({ force: true })\n        } catch {\n          // Best effort cleanup\n        }\n\n        // Restore old container\n        const oldContainers = await this.docker.listContainers({ all: true })\n        const oldRef = oldContainers.find((c) => c.Names.includes(`/${oldName}`))\n        if (oldRef) {\n          const rollbackContainer = this.docker.getContainer(oldRef.Id)\n          await rollbackContainer.rename({ name: serviceName })\n          await rollbackContainer.start()\n        }\n\n        this.activeInstallations.delete(serviceName)\n        return {\n          success: false,\n          message: `Update failed: new container did not stay running. Rolled back to previous version.`,\n        }\n      }\n    } catch (error) {\n      this.activeInstallations.delete(serviceName)\n      this._broadcast(\n        serviceName,\n        'update-rollback',\n        `Update failed: ${error.message}`\n      )\n      logger.error(`[DockerService] Update failed for ${serviceName}: ${error.message}`)\n      return { success: false, message: `Update failed: ${error.message}` }\n    }\n  }\n\n  private _broadcast(service: string, status: string, message: string) {\n    transmit.broadcast(BROADCAST_CHANNELS.SERVICE_INSTALLATION, {\n      service_name: service,\n      timestamp: new Date().toISOString(),\n      status,\n      message,\n    })\n    logger.info(`[DockerService] [${service}] ${status}: ${message}`)\n  }\n\n  private _parseContainerConfig(containerConfig: any): any {\n    if (!containerConfig) {\n      return {}\n    }\n\n    try {\n      // Handle the case where containerConfig is returned as an object by DB instead of a string\n      let toParse = containerConfig\n      if (typeof containerConfig === 'object') {\n        toParse = JSON.stringify(containerConfig)\n      }\n\n      return JSON.parse(toParse)\n    } catch (error) {\n      logger.error(`Failed to parse container configuration: ${error.message}`)\n      throw new Error(`Invalid container configuration: ${error.message}`)\n    }\n  }\n\n  /**\n   * Check if a Docker image exists locally.\n   * @param imageName - The name and tag of the image (e.g., \"nginx:latest\")\n   * @returns - True if the image exists locally, false otherwise\n   */\n  private async _checkImageExists(imageName: string): Promise<boolean> {\n    try {\n      const images = await this.docker.listImages()\n\n      // Check if any image has a RepoTag that matches the requested image\n      return images.some((image) => image.RepoTags && image.RepoTags.includes(imageName))\n    } catch (error) {\n      logger.warn(`Error checking if image exists: ${error.message}`)\n      // If run into an error, assume the image does not exist\n      return false\n    }\n  }\n}\n"
  },
  {
    "path": "admin/app/services/docs_service.ts",
    "content": "import Markdoc from '@markdoc/markdoc'\nimport { streamToString } from '../../util/docs.js'\nimport { getFile, getFileStatsIfExists, listDirectoryContentsRecursive } from '../utils/fs.js'\nimport path from 'path'\nimport InternalServerErrorException from '#exceptions/internal_server_error_exception'\nimport logger from '@adonisjs/core/services/logger'\n\nexport class DocsService {\n  private docsPath = path.join(process.cwd(), 'docs')\n\n  private static readonly DOC_ORDER: Record<string, number> = {\n    'home': 1,\n    'getting-started': 2,\n    'use-cases': 3,\n    'faq': 4,\n    'about': 5,\n    'release-notes': 6,\n  }\n\n  async getDocs() {\n    const contents = await listDirectoryContentsRecursive(this.docsPath)\n    const files: Array<{ title: string; slug: string }> = []\n\n    for (const item of contents) {\n      if (item.type === 'file' && item.name.endsWith('.md')) {\n        const cleaned = this.prettify(item.name)\n        files.push({\n          title: cleaned,\n          slug: item.name.replace(/\\.md$/, ''),\n        })\n      }\n    }\n\n    return files.sort((a, b) => {\n      const orderA = DocsService.DOC_ORDER[a.slug] ?? 999\n      const orderB = DocsService.DOC_ORDER[b.slug] ?? 999\n      return orderA - orderB\n    })\n  }\n\n  parse(content: string) {\n    try {\n      const ast = Markdoc.parse(content)\n      const config = this.getConfig()\n      const errors = Markdoc.validate(ast, config)\n\n      // Filter out attribute-undefined errors which may be caused by emojis and special characters\n      const criticalErrors = errors.filter((e) => e.error.id !== 'attribute-undefined')\n      if (criticalErrors.length > 0) {\n        logger.error('Markdoc validation errors:', errors.map((e) => JSON.stringify(e.error)).join(', '))\n        throw new Error('Markdoc validation failed')\n      }\n\n      return Markdoc.transform(ast, config)\n    } catch (error) {\n      logger.error('Error parsing Markdoc content:', error)\n      throw new InternalServerErrorException(`Error parsing content: ${(error as Error).message}`)\n    }\n  }\n\n  async parseFile(_filename: string) {\n    try {\n      if (!_filename) {\n        throw new Error('Filename is required')\n      }\n\n      const filename = _filename.endsWith('.md') ? _filename : `${_filename}.md`\n\n      // Prevent path traversal — resolved path must stay within the docs directory\n      const basePath = path.resolve(this.docsPath)\n      const fullPath = path.resolve(path.join(this.docsPath, filename))\n      if (!fullPath.startsWith(basePath + path.sep)) {\n        throw new Error('Invalid document slug')\n      }\n\n      const fileExists = await getFileStatsIfExists(fullPath)\n      if (!fileExists) {\n        throw new Error(`File not found: ${filename}`)\n      }\n\n      const fileStream = await getFile(fullPath, 'stream')\n      if (!fileStream) {\n        throw new Error(`Failed to read file stream: ${filename}`)\n      }\n      const content = await streamToString(fileStream)\n      return this.parse(content)\n    } catch (error) {\n      throw new InternalServerErrorException(`Error parsing file: ${(error as Error).message}`)\n    }\n  }\n\n  private static readonly TITLE_OVERRIDES: Record<string, string> = {\n    'faq': 'FAQ',\n  }\n\n  private prettify(filename: string) {\n    const slug = filename.replace(/\\.md$/, '')\n    if (DocsService.TITLE_OVERRIDES[slug]) {\n      return DocsService.TITLE_OVERRIDES[slug]\n    }\n    // Remove hyphens, underscores, and file extension\n    const cleaned = slug.replace(/_/g, ' ').replace(/-/g, ' ')\n    // Convert to Title Case\n    const titleCased = cleaned.replace(/\\b\\w/g, (char) => char.toUpperCase())\n    return titleCased.charAt(0).toUpperCase() + titleCased.slice(1)\n  }\n\n  private getConfig() {\n    return {\n      tags: {\n        callout: {\n          render: 'Callout',\n          attributes: {\n            type: {\n              type: String,\n              default: 'info',\n              matches: ['info', 'warning', 'error', 'success'],\n            },\n            title: {\n              type: String,\n            },\n          },\n        },\n      },\n      nodes: {\n        heading: {\n          render: 'Heading',\n          attributes: {\n            level: { type: Number, required: true },\n            id: { type: String },\n          },\n        },\n        list: {\n          render: 'List',\n          attributes: {\n            ordered: { type: Boolean },\n            start: { type: Number },\n          },\n        },\n        list_item: {\n          render: 'ListItem',\n          attributes: {\n            marker: { type: String },\n            className: { type: String },\n            class: { type: String }\n          }\n        },\n        table: {\n          render: 'Table',\n        },\n        thead: {\n          render: 'TableHead',\n        },\n        tbody: {\n          render: 'TableBody',\n        },\n        tr: {\n          render: 'TableRow',\n        },\n        th: {\n          render: 'TableHeader',\n        },\n        td: {\n          render: 'TableCell',\n        },\n        paragraph: {\n          render: 'Paragraph',\n        },\n        image: {\n          render: 'Image',\n          attributes: {\n            src: { type: String, required: true },\n            alt: { type: String },\n            title: { type: String },\n          },\n        },\n        link: {\n          render: 'Link',\n          attributes: {\n            href: { type: String, required: true },\n            title: { type: String },\n          },\n        },\n        fence: {\n          render: 'CodeBlock',\n          attributes: {\n            content: { type: String },\n            language: { type: String },\n          },\n        },\n        code: {\n          render: 'InlineCode',\n          attributes: {\n            content: { type: String },\n          },\n        },\n        hr: {\n          render: 'HorizontalRule',\n        },\n      },\n    }\n  }\n}\n"
  },
  {
    "path": "admin/app/services/download_service.ts",
    "content": "import { inject } from '@adonisjs/core'\nimport { QueueService } from './queue_service.js'\nimport { RunDownloadJob } from '#jobs/run_download_job'\nimport { DownloadModelJob } from '#jobs/download_model_job'\nimport { DownloadJobWithProgress } from '../../types/downloads.js'\nimport { normalize } from 'path'\n\n@inject()\nexport class DownloadService {\n  constructor(private queueService: QueueService) {}\n\n  async listDownloadJobs(filetype?: string): Promise<DownloadJobWithProgress[]> {\n    // Get regular file download jobs (zim, map, etc.)\n    const queue = this.queueService.getQueue(RunDownloadJob.queue)\n    const fileJobs = await queue.getJobs(['waiting', 'active', 'delayed', 'failed'])\n\n    const fileDownloads = fileJobs.map((job) => ({\n      jobId: job.id!.toString(),\n      url: job.data.url,\n      progress: parseInt(job.progress.toString(), 10),\n      filepath: normalize(job.data.filepath),\n      filetype: job.data.filetype,\n      status: (job.failedReason ? 'failed' : 'active') as 'active' | 'failed',\n      failedReason: job.failedReason || undefined,\n    }))\n\n    // Get Ollama model download jobs\n    const modelQueue = this.queueService.getQueue(DownloadModelJob.queue)\n    const modelJobs = await modelQueue.getJobs(['waiting', 'active', 'delayed', 'failed'])\n\n    const modelDownloads = modelJobs.map((job) => ({\n      jobId: job.id!.toString(),\n      url: job.data.modelName || 'Unknown Model', // Use model name as url\n      progress: parseInt(job.progress.toString(), 10),\n      filepath: job.data.modelName || 'Unknown Model', // Use model name as filepath\n      filetype: 'model',\n      status: (job.failedReason ? 'failed' : 'active') as 'active' | 'failed',\n      failedReason: job.failedReason || undefined,\n    }))\n\n    const allDownloads = [...fileDownloads, ...modelDownloads]\n\n    // Filter by filetype if specified\n    const filtered = allDownloads.filter((job) => !filetype || job.filetype === filetype)\n\n    // Sort: active downloads first (by progress desc), then failed at the bottom\n    return filtered.sort((a, b) => {\n      if (a.status === 'failed' && b.status !== 'failed') return 1\n      if (a.status !== 'failed' && b.status === 'failed') return -1\n      return b.progress - a.progress\n    })\n  }\n\n  async removeFailedJob(jobId: string): Promise<void> {\n    for (const queueName of [RunDownloadJob.queue, DownloadModelJob.queue]) {\n      const queue = this.queueService.getQueue(queueName)\n      const job = await queue.getJob(jobId)\n      if (job) {\n        await job.remove()\n        return\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "admin/app/services/map_service.ts",
    "content": "import { BaseStylesFile, MapLayer } from '../../types/maps.js'\nimport {\n  DownloadRemoteSuccessCallback,\n  FileEntry,\n} from '../../types/files.js'\nimport { doResumableDownloadWithRetry } from '../utils/downloads.js'\nimport { extract } from 'tar'\nimport env from '#start/env'\nimport {\n  listDirectoryContentsRecursive,\n  getFileStatsIfExists,\n  deleteFileIfExists,\n  getFile,\n  ensureDirectoryExists,\n} from '../utils/fs.js'\nimport { join, resolve, sep } from 'path'\nimport urlJoin from 'url-join'\nimport { RunDownloadJob } from '#jobs/run_download_job'\nimport logger from '@adonisjs/core/services/logger'\nimport InstalledResource from '#models/installed_resource'\nimport { CollectionManifestService } from './collection_manifest_service.js'\nimport type { CollectionWithStatus, MapsSpec } from '../../types/collections.js'\n\nconst BASE_ASSETS_MIME_TYPES = [\n  'application/gzip',\n  'application/x-gzip',\n  'application/octet-stream',\n]\n\nconst PMTILES_ATTRIBUTION =\n  '<a href=\"https://github.com/protomaps/basemaps\">Protomaps</a> © <a href=\"https://openstreetmap.org\">OpenStreetMap</a>'\nconst PMTILES_MIME_TYPES = ['application/vnd.pmtiles', 'application/octet-stream']\n\ninterface IMapService {\n  downloadRemoteSuccessCallback: DownloadRemoteSuccessCallback\n}\n\nexport class MapService implements IMapService {\n  private readonly mapStoragePath = '/storage/maps'\n  private readonly baseStylesFile = 'nomad-base-styles.json'\n  private readonly basemapsAssetsDir = 'basemaps-assets'\n  private readonly baseAssetsTarFile = 'base-assets.tar.gz'\n  private readonly baseDirPath = join(process.cwd(), this.mapStoragePath)\n  private baseAssetsExistCache: boolean | null = null\n\n  async listRegions() {\n    const files = (await this.listAllMapStorageItems()).filter(\n      (item) => item.type === 'file' && item.name.endsWith('.pmtiles')\n    )\n\n    return {\n      files,\n    }\n  }\n\n  async downloadBaseAssets(url?: string) {\n    const tempTarPath = join(this.baseDirPath, this.baseAssetsTarFile)\n\n    const defaultTarFileURL = new URL(\n      this.baseAssetsTarFile,\n      'https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/'\n    )\n\n    const resolvedURL = url ? new URL(url) : defaultTarFileURL\n    await doResumableDownloadWithRetry({\n      url: resolvedURL.toString(),\n      filepath: tempTarPath,\n      timeout: 30000,\n      max_retries: 2,\n      allowedMimeTypes: BASE_ASSETS_MIME_TYPES,\n      onAttemptError(error, attempt) {\n        console.error(`Attempt ${attempt} to download tar file failed: ${error.message}`)\n      },\n    })\n    const tarFileBuffer = await getFileStatsIfExists(tempTarPath)\n    if (!tarFileBuffer) {\n      throw new Error(`Failed to download tar file`)\n    }\n\n    await extract({\n      cwd: join(process.cwd(), this.mapStoragePath),\n      file: tempTarPath,\n      strip: 1,\n    })\n\n    await deleteFileIfExists(tempTarPath)\n\n    // Invalidate cache since we just downloaded new assets\n    this.baseAssetsExistCache = true\n\n    return true\n  }\n\n  async downloadCollection(slug: string): Promise<string[] | null> {\n    const manifestService = new CollectionManifestService()\n    const spec = await manifestService.getSpecWithFallback<MapsSpec>('maps')\n    if (!spec) return null\n\n    const collection = spec.collections.find((c) => c.slug === slug)\n    if (!collection) return null\n\n    // Filter out already installed\n    const installed = await InstalledResource.query().where('resource_type', 'map')\n    const installedIds = new Set(installed.map((r) => r.resource_id))\n    const toDownload = collection.resources.filter((r) => !installedIds.has(r.id))\n\n    if (toDownload.length === 0) return null\n\n    const downloadFilenames: string[] = []\n\n    for (const resource of toDownload) {\n      const existing = await RunDownloadJob.getByUrl(resource.url)\n      if (existing) {\n        logger.warn(`[MapService] Download already in progress for URL ${resource.url}, skipping.`)\n        continue\n      }\n\n      const filename = resource.url.split('/').pop()\n      if (!filename) {\n        logger.warn(`[MapService] Could not determine filename from URL ${resource.url}, skipping.`)\n        continue\n      }\n\n      downloadFilenames.push(filename)\n      const filepath = join(process.cwd(), this.mapStoragePath, 'pmtiles', filename)\n\n      await RunDownloadJob.dispatch({\n        url: resource.url,\n        filepath,\n        timeout: 30000,\n        allowedMimeTypes: PMTILES_MIME_TYPES,\n        forceNew: true,\n        filetype: 'map',\n        resourceMetadata: {\n          resource_id: resource.id,\n          version: resource.version,\n          collection_ref: slug,\n        },\n      })\n    }\n\n    return downloadFilenames.length > 0 ? downloadFilenames : null\n  }\n\n  async downloadRemoteSuccessCallback(urls: string[], _: boolean) {\n    // Create InstalledResource entries for downloaded map files\n    for (const url of urls) {\n      const filename = url.split('/').pop()\n      if (!filename) continue\n\n      const parsed = CollectionManifestService.parseMapFilename(filename)\n      if (!parsed) continue\n\n      const filepath = join(process.cwd(), this.mapStoragePath, 'pmtiles', filename)\n      const stats = await getFileStatsIfExists(filepath)\n\n      try {\n        const { DateTime } = await import('luxon')\n        await InstalledResource.updateOrCreate(\n          { resource_id: parsed.resource_id, resource_type: 'map' },\n          {\n            version: parsed.version,\n            url: url,\n            file_path: filepath,\n            file_size_bytes: stats ? Number(stats.size) : null,\n            installed_at: DateTime.now(),\n          }\n        )\n        logger.info(`[MapService] Created InstalledResource entry for: ${parsed.resource_id}`)\n      } catch (error) {\n        logger.error(`[MapService] Failed to create InstalledResource for ${filename}:`, error)\n      }\n    }\n  }\n\n  async downloadRemote(url: string): Promise<{ filename: string; jobId?: string }> {\n    const parsed = new URL(url)\n    if (!parsed.pathname.endsWith('.pmtiles')) {\n      throw new Error(`Invalid PMTiles file URL: ${url}. URL must end with .pmtiles`)\n    }\n\n    const existing = await RunDownloadJob.getByUrl(url)\n    if (existing) {\n      throw new Error(`Download already in progress for URL ${url}`)\n    }\n\n    const filename = url.split('/').pop()\n    if (!filename) {\n      throw new Error('Could not determine filename from URL')\n    }\n\n    const filepath = join(process.cwd(), this.mapStoragePath, 'pmtiles', filename)\n\n\n    // First, ensure base assets are present - regions depend on them\n    const baseAssetsExist = await this.ensureBaseAssets()\n    if (!baseAssetsExist) {\n      throw new Error(\n        'Base map assets are missing and could not be downloaded. Please check your connection and try again.'\n      )\n    }\n\n    // Parse resource metadata\n    const parsedFilename = CollectionManifestService.parseMapFilename(filename)\n    const resourceMetadata = parsedFilename\n      ? { resource_id: parsedFilename.resource_id, version: parsedFilename.version, collection_ref: null }\n      : undefined\n\n    // Dispatch background job\n    const result = await RunDownloadJob.dispatch({\n      url,\n      filepath,\n      timeout: 30000,\n      allowedMimeTypes: PMTILES_MIME_TYPES,\n      forceNew: true,\n      filetype: 'map',\n      resourceMetadata,\n    })\n\n    if (!result.job) {\n      throw new Error('Failed to dispatch download job')\n    }\n\n    logger.info(`[MapService] Dispatched download job ${result.job.id} for URL ${url}`)\n\n    return {\n      filename,\n      jobId: result.job?.id,\n    }\n  }\n\n  async downloadRemotePreflight(\n    url: string\n  ): Promise<{ filename: string; size: number } | { message: string }> {\n    try {\n      const parsed = new URL(url)\n      if (!parsed.pathname.endsWith('.pmtiles')) {\n        throw new Error(`Invalid PMTiles file URL: ${url}. URL must end with .pmtiles`)\n      }\n\n      const filename = url.split('/').pop()\n      if (!filename) {\n        throw new Error('Could not determine filename from URL')\n      }\n\n      // Perform a HEAD request to get the content length\n      const { default: axios } = await import('axios')\n      const response = await axios.head(url)\n\n      if (response.status !== 200) {\n        throw new Error(`Failed to fetch file info: ${response.status} ${response.statusText}`)\n      }\n\n      const contentLength = response.headers['content-length']\n      const size = contentLength ? parseInt(contentLength, 10) : 0\n\n      return { filename, size }\n    } catch (error: any) {\n      return { message: `Preflight check failed: ${error.message}` }\n    }\n  }\n\n  async generateStylesJSON(host: string | null = null, protocol: string = 'http'): Promise<BaseStylesFile> {\n    if (!(await this.checkBaseAssetsExist())) {\n      throw new Error('Base map assets are missing from storage/maps')\n    }\n\n    const baseStylePath = join(this.baseDirPath, this.baseStylesFile)\n    const baseStyle = await getFile(baseStylePath, 'string')\n    if (!baseStyle) {\n      throw new Error('Base styles file not found in storage/maps')\n    }\n\n    const rawStyles = JSON.parse(baseStyle.toString()) as BaseStylesFile\n\n    const regions = (await this.listRegions()).files\n\n    /** If we have the host, use it to build public URLs, otherwise we'll fallback to defaults\n    * This is mainly useful because we need to know what host the user is accessing from in order to\n    * properly generate URLs in the styles file\n    * e.g. user is accessing from \"example.com\", but we would by default generate \"localhost:8080/...\" so maps would\n    * fail to load.\n    */\n    const sources = this.generateSourcesArray(host, regions, protocol)\n    const baseUrl = this.getPublicFileBaseUrl(host, this.basemapsAssetsDir, protocol)\n\n    const styles = await this.generateStylesFile(\n      rawStyles,\n      sources,\n      urlJoin(baseUrl, 'sprites/v4/light'),\n      urlJoin(baseUrl, 'fonts/{fontstack}/{range}.pbf')\n    )\n\n    return styles\n  }\n\n  async listCuratedCollections(): Promise<CollectionWithStatus[]> {\n    const manifestService = new CollectionManifestService()\n    return manifestService.getMapCollectionsWithStatus()\n  }\n\n  async fetchLatestCollections(): Promise<boolean> {\n    const manifestService = new CollectionManifestService()\n    return manifestService.fetchAndCacheSpec('maps')\n  }\n\n  async ensureBaseAssets(): Promise<boolean> {\n    const exists = await this.checkBaseAssetsExist()\n    if (exists) {\n      return true\n    }\n\n    return await this.downloadBaseAssets()\n  }\n\n  private async checkBaseAssetsExist(useCache: boolean = true): Promise<boolean> {\n    // Return cached result if available and caching is enabled\n    if (useCache && this.baseAssetsExistCache !== null) {\n      return this.baseAssetsExistCache\n    }\n\n    await ensureDirectoryExists(this.baseDirPath)\n\n    const baseStylePath = join(this.baseDirPath, this.baseStylesFile)\n    const basemapsAssetsPath = join(this.baseDirPath, this.basemapsAssetsDir)\n\n    const [baseStyleExists, basemapsAssetsExists] = await Promise.all([\n      getFileStatsIfExists(baseStylePath),\n      getFileStatsIfExists(basemapsAssetsPath),\n    ])\n\n    const exists = !!baseStyleExists && !!basemapsAssetsExists\n\n    // update cache\n    this.baseAssetsExistCache = exists\n\n    return exists\n  }\n\n  private async listAllMapStorageItems(): Promise<FileEntry[]> {\n    await ensureDirectoryExists(this.baseDirPath)\n    return await listDirectoryContentsRecursive(this.baseDirPath)\n  }\n\n  private generateSourcesArray(host: string | null, regions: FileEntry[], protocol: string = 'http'): BaseStylesFile['sources'][] {\n    const sources: BaseStylesFile['sources'][] = []\n    const baseUrl = this.getPublicFileBaseUrl(host, 'pmtiles', protocol)\n\n    for (const region of regions) {\n      if (region.type === 'file' && region.name.endsWith('.pmtiles')) {\n        // Strip .pmtiles and date suffix (e.g. \"alaska_2025-12\" -> \"alaska\") for stable source names\n        const parsed = CollectionManifestService.parseMapFilename(region.name)\n        const regionName = parsed ? parsed.resource_id : region.name.replace('.pmtiles', '')\n        const source: BaseStylesFile['sources'] = {}\n        const sourceUrl = urlJoin(baseUrl, region.name)\n\n        source[regionName] = {\n          type: 'vector',\n          attribution: PMTILES_ATTRIBUTION,\n          url: `pmtiles://${sourceUrl}`,\n        }\n        sources.push(source)\n      }\n    }\n\n    return sources\n  }\n\n  private async generateStylesFile(\n    template: BaseStylesFile,\n    sources: BaseStylesFile['sources'][],\n    sprites: string,\n    glyphs: string\n  ): Promise<BaseStylesFile> {\n    const layersTemplates = template.layers.filter((layer) => layer.source)\n    const withoutSources = template.layers.filter((layer) => !layer.source)\n\n    template.sources = {} // Clear existing sources\n    template.layers = [...withoutSources] // Start with layers that don't depend on sources\n\n    for (const source of sources) {\n      for (const layerTemplate of layersTemplates) {\n        const layer: MapLayer = {\n          ...layerTemplate,\n          id: `${layerTemplate.id}-${Object.keys(source)[0]}`,\n          type: layerTemplate.type,\n          source: Object.keys(source)[0],\n        }\n        template.layers.push(layer)\n      }\n\n      template.sources = Object.assign(template.sources, source)\n    }\n\n    template.sprite = sprites\n    template.glyphs = glyphs\n\n    return template\n  }\n\n  async delete(file: string): Promise<void> {\n    let fileName = file\n    if (!fileName.endsWith('.pmtiles')) {\n      fileName += '.pmtiles'\n    }\n\n    const basePath = resolve(join(this.baseDirPath, 'pmtiles'))\n    const fullPath = resolve(join(basePath, fileName))\n\n    // Prevent path traversal — resolved path must stay within the storage directory\n    if (!fullPath.startsWith(basePath + sep)) {\n      throw new Error('Invalid filename')\n    }\n\n    const exists = await getFileStatsIfExists(fullPath)\n    if (!exists) {\n      throw new Error('not_found')\n    }\n\n    await deleteFileIfExists(fullPath)\n\n    // Clean up InstalledResource entry\n    const parsed = CollectionManifestService.parseMapFilename(fileName)\n    if (parsed) {\n      await InstalledResource.query()\n        .where('resource_id', parsed.resource_id)\n        .where('resource_type', 'map')\n        .delete()\n      logger.info(`[MapService] Deleted InstalledResource entry for: ${parsed.resource_id}`)\n    }\n  }\n\n  /*\n   * Gets the appropriate public URL for a map asset depending on environment\n   */\n  private getPublicFileBaseUrl(specifiedHost: string | null, childPath: string, protocol: string = 'http'): string {\n    function getHost() {\n      try {\n        const localUrlRaw = env.get('URL')\n        if (!localUrlRaw) return 'localhost'\n\n        const localUrl = new URL(localUrlRaw)\n        return localUrl.host\n      } catch (error) {\n        return 'localhost'\n      }\n    }\n\n    const host = specifiedHost || getHost()\n    const withProtocol = host.startsWith('http') ? host : `${protocol}://${host}`\n    const baseUrlPath =\n      process.env.NODE_ENV === 'production' ? childPath : urlJoin(this.mapStoragePath, childPath)\n\n    const baseUrl = new URL(baseUrlPath, withProtocol).toString()\n    return baseUrl\n  }\n}\n"
  },
  {
    "path": "admin/app/services/ollama_service.ts",
    "content": "import { inject } from '@adonisjs/core'\nimport { ChatRequest, Ollama } from 'ollama'\nimport { NomadOllamaModel } from '../../types/ollama.js'\nimport { FALLBACK_RECOMMENDED_OLLAMA_MODELS } from '../../constants/ollama.js'\nimport fs from 'node:fs/promises'\nimport path from 'node:path'\nimport logger from '@adonisjs/core/services/logger'\nimport axios from 'axios'\nimport { DownloadModelJob } from '#jobs/download_model_job'\nimport { SERVICE_NAMES } from '../../constants/service_names.js'\nimport transmit from '@adonisjs/transmit/services/main'\nimport Fuse, { IFuseOptions } from 'fuse.js'\nimport { BROADCAST_CHANNELS } from '../../constants/broadcast.js'\nimport env from '#start/env'\nimport { NOMAD_API_DEFAULT_BASE_URL } from '../../constants/misc.js'\n\nconst NOMAD_MODELS_API_PATH = '/api/v1/ollama/models'\nconst MODELS_CACHE_FILE = path.join(process.cwd(), 'storage', 'ollama-models-cache.json')\nconst CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000 // 24 hours\n\n@inject()\nexport class OllamaService {\n  private ollama: Ollama | null = null\n  private ollamaInitPromise: Promise<void> | null = null\n\n  constructor() { }\n\n  private async _initializeOllamaClient() {\n    if (!this.ollamaInitPromise) {\n      this.ollamaInitPromise = (async () => {\n        const dockerService = new (await import('./docker_service.js')).DockerService()\n        const qdrantUrl = await dockerService.getServiceURL(SERVICE_NAMES.OLLAMA)\n        if (!qdrantUrl) {\n          throw new Error('Ollama service is not installed or running.')\n        }\n        this.ollama = new Ollama({ host: qdrantUrl })\n      })()\n    }\n    return this.ollamaInitPromise\n  }\n\n  private async _ensureDependencies() {\n    if (!this.ollama) {\n      await this._initializeOllamaClient()\n    }\n  }\n\n  /**\n   * Downloads a model from the Ollama service with progress tracking. Where possible,\n   * one should dispatch a background job instead of calling this method directly to avoid long blocking.\n   * @param model Model name to download\n   * @returns Success status and message\n   */\n  async downloadModel(model: string, progressCallback?: (percent: number) => void): Promise<{ success: boolean; message: string }> {\n    try {\n      await this._ensureDependencies()\n      if (!this.ollama) {\n        throw new Error('Ollama client is not initialized.')\n      }\n\n      // See if model is already installed\n      const installedModels = await this.getModels()\n      if (installedModels && installedModels.some((m) => m.name === model)) {\n        logger.info(`[OllamaService] Model \"${model}\" is already installed.`)\n        return { success: true, message: 'Model is already installed.' }\n      }\n\n      // Returns AbortableAsyncIterator<ProgressResponse>\n      const downloadStream = await this.ollama.pull({\n        model,\n        stream: true,\n      })\n\n      for await (const chunk of downloadStream) {\n        if (chunk.completed && chunk.total) {\n          const percent = ((chunk.completed / chunk.total) * 100).toFixed(2)\n          const percentNum = parseFloat(percent)\n\n          this.broadcastDownloadProgress(model, percentNum)\n          if (progressCallback) {\n            progressCallback(percentNum)\n          }\n        }\n      }\n\n      logger.info(`[OllamaService] Model \"${model}\" downloaded successfully.`)\n      return { success: true, message: 'Model downloaded successfully.' }\n    } catch (error) {\n      logger.error(\n        `[OllamaService] Failed to download model \"${model}\": ${error instanceof Error ? error.message : error\n        }`\n      )\n      return { success: false, message: 'Failed to download model.' }\n    }\n  }\n\n  async dispatchModelDownload(modelName: string): Promise<{ success: boolean; message: string }> {\n    try {\n      logger.info(`[OllamaService] Dispatching model download for ${modelName} via job queue`)\n\n      await DownloadModelJob.dispatch({\n        modelName,\n      })\n\n      return {\n        success: true,\n        message:\n          'Model download has been queued successfully. It will start shortly after Ollama and Open WebUI are ready (if not already).',\n      }\n    } catch (error) {\n      logger.error(\n        `[OllamaService] Failed to dispatch model download for ${modelName}: ${error instanceof Error ? error.message : error}`\n      )\n      return {\n        success: false,\n        message: 'Failed to queue model download. Please try again.',\n      }\n    }\n  }\n\n  public async getClient() {\n    await this._ensureDependencies()\n    return this.ollama!\n  }\n\n  public async chat(chatRequest: ChatRequest & { stream?: boolean }) {\n    await this._ensureDependencies()\n    if (!this.ollama) {\n      throw new Error('Ollama client is not initialized.')\n    }\n    return await this.ollama.chat({\n      ...chatRequest,\n      stream: false,\n    })\n  }\n\n  public async chatStream(chatRequest: ChatRequest) {\n    await this._ensureDependencies()\n    if (!this.ollama) {\n      throw new Error('Ollama client is not initialized.')\n    }\n    return await this.ollama.chat({\n      ...chatRequest,\n      stream: true,\n    })\n  }\n\n  public async checkModelHasThinking(modelName: string): Promise<boolean> {\n    await this._ensureDependencies()\n    if (!this.ollama) {\n      throw new Error('Ollama client is not initialized.')\n    }\n\n    const modelInfo = await this.ollama.show({\n      model: modelName,\n    })\n\n    return modelInfo.capabilities.includes('thinking')\n  }\n\n  public async deleteModel(modelName: string) {\n    await this._ensureDependencies()\n    if (!this.ollama) {\n      throw new Error('Ollama client is not initialized.')\n    }\n\n    return await this.ollama.delete({\n      model: modelName,\n    })\n  }\n\n  public async getModels(includeEmbeddings = false) {\n    await this._ensureDependencies()\n    if (!this.ollama) {\n      throw new Error('Ollama client is not initialized.')\n    }\n    const response = await this.ollama.list()\n    if (includeEmbeddings) {\n      return response.models\n    }\n    // Filter out embedding models\n    return response.models.filter((model) => !model.name.includes('embed'))\n  }\n\n  async getAvailableModels(\n    { sort, recommendedOnly, query, limit, force }: { sort?: 'pulls' | 'name'; recommendedOnly?: boolean, query: string | null, limit?: number, force?: boolean } = {\n      sort: 'pulls',\n      recommendedOnly: false,\n      query: null,\n      limit: 15,\n    }\n  ): Promise<{ models: NomadOllamaModel[], hasMore: boolean } | null> {\n    try {\n      const models = await this.retrieveAndRefreshModels(sort, force)\n      if (!models) {\n        // If we fail to get models from the API, return the fallback recommended models\n        logger.warn(\n          '[OllamaService] Returning fallback recommended models due to failure in fetching available models'\n        )\n        return {\n          models: FALLBACK_RECOMMENDED_OLLAMA_MODELS,\n          hasMore: false\n        }\n      }\n\n      if (!recommendedOnly) {\n        const filteredModels = query ? this.fuseSearchModels(models, query) : models\n        return {\n          models: filteredModels.slice(0, limit || 15),\n          hasMore: filteredModels.length > (limit || 15)\n        }\n      }\n\n      // If recommendedOnly is true, only return the first three models (if sorted by pulls, these will be the top 3)\n      const sortedByPulls = sort === 'pulls' ? models : this.sortModels(models, 'pulls')\n      const firstThree = sortedByPulls.slice(0, 3)\n\n      // Only return the first tag of each of these models (should be the most lightweight variant)\n      const recommendedModels = firstThree.map((model) => {\n        return {\n          ...model,\n          tags: model.tags && model.tags.length > 0 ? [model.tags[0]] : [],\n        }\n      })\n\n      if (query) {\n        const filteredRecommendedModels = this.fuseSearchModels(recommendedModels, query)\n        return {\n          models: filteredRecommendedModels,\n          hasMore: filteredRecommendedModels.length > (limit || 15)\n        }\n      }\n\n      return {\n        models: recommendedModels,\n        hasMore: recommendedModels.length > (limit || 15)\n      }\n    } catch (error) {\n      logger.error(\n        `[OllamaService] Failed to get available models: ${error instanceof Error ? error.message : error}`\n      )\n      return null\n    }\n  }\n\n  private async retrieveAndRefreshModels(\n    sort?: 'pulls' | 'name',\n    force?: boolean\n  ): Promise<NomadOllamaModel[] | null> {\n    try {\n      if (!force) {\n        const cachedModels = await this.readModelsFromCache()\n        if (cachedModels) {\n          logger.info('[OllamaService] Using cached available models data')\n          return this.sortModels(cachedModels, sort)\n        }\n      } else {\n        logger.info('[OllamaService] Force refresh requested, bypassing cache')\n      }\n\n      logger.info('[OllamaService] Fetching fresh available models from API')\n\n      const baseUrl = env.get('NOMAD_API_URL') || NOMAD_API_DEFAULT_BASE_URL\n      const fullUrl = new URL(NOMAD_MODELS_API_PATH, baseUrl).toString()\n\n      const response = await axios.get(fullUrl)\n      if (!response.data || !Array.isArray(response.data.models)) {\n        logger.warn(\n          `[OllamaService] Invalid response format when fetching available models: ${JSON.stringify(response.data)}`\n        )\n        return null\n      }\n\n      const rawModels = response.data.models as NomadOllamaModel[]\n\n      // Filter out tags where cloud is truthy, then remove models with no remaining tags\n      const noCloud = rawModels\n        .map((model) => ({\n          ...model,\n          tags: model.tags.filter((tag) => !tag.cloud),\n        }))\n        .filter((model) => model.tags.length > 0)\n\n      await this.writeModelsToCache(noCloud)\n      return this.sortModels(noCloud, sort)\n    } catch (error) {\n      logger.error(\n        `[OllamaService] Failed to retrieve models from Nomad API: ${error instanceof Error ? error.message : error\n        }`\n      )\n      return null\n    }\n  }\n\n  private async readModelsFromCache(): Promise<NomadOllamaModel[] | null> {\n    try {\n      const stats = await fs.stat(MODELS_CACHE_FILE)\n      const cacheAge = Date.now() - stats.mtimeMs\n\n      if (cacheAge > CACHE_MAX_AGE_MS) {\n        logger.info('[OllamaService] Cache is stale, will fetch fresh data')\n        return null\n      }\n\n      const cacheData = await fs.readFile(MODELS_CACHE_FILE, 'utf-8')\n      const models = JSON.parse(cacheData) as NomadOllamaModel[]\n\n      if (!Array.isArray(models)) {\n        logger.warn('[OllamaService] Invalid cache format, will fetch fresh data')\n        return null\n      }\n\n      return models\n    } catch (error) {\n      // Cache doesn't exist or is invalid\n      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {\n        logger.warn(\n          `[OllamaService] Error reading cache: ${error instanceof Error ? error.message : error}`\n        )\n      }\n      return null\n    }\n  }\n\n  private async writeModelsToCache(models: NomadOllamaModel[]): Promise<void> {\n    try {\n      await fs.mkdir(path.dirname(MODELS_CACHE_FILE), { recursive: true })\n      await fs.writeFile(MODELS_CACHE_FILE, JSON.stringify(models, null, 2), 'utf-8')\n      logger.info('[OllamaService] Successfully cached available models')\n    } catch (error) {\n      logger.warn(\n        `[OllamaService] Failed to write models cache: ${error instanceof Error ? error.message : error}`\n      )\n    }\n  }\n\n  private sortModels(models: NomadOllamaModel[], sort?: 'pulls' | 'name'): NomadOllamaModel[] {\n    if (sort === 'pulls') {\n      // Sort by estimated pulls (it should be a string like \"1.2K\", \"500\", \"4M\" etc.)\n      models.sort((a, b) => {\n        const parsePulls = (pulls: string) => {\n          const multiplier = pulls.endsWith('K')\n            ? 1_000\n            : pulls.endsWith('M')\n              ? 1_000_000\n              : pulls.endsWith('B')\n                ? 1_000_000_000\n                : 1\n          return parseFloat(pulls) * multiplier\n        }\n        return parsePulls(b.estimated_pulls) - parsePulls(a.estimated_pulls)\n      })\n    } else if (sort === 'name') {\n      models.sort((a, b) => a.name.localeCompare(b.name))\n    }\n\n    // Always sort model.tags by the size field in descending order\n    // Size is a string like '75GB', '8.5GB', '2GB' etc. Smaller models first\n    models.forEach((model) => {\n      if (model.tags && Array.isArray(model.tags)) {\n        model.tags.sort((a, b) => {\n          const parseSize = (size: string) => {\n            const multiplier = size.endsWith('KB')\n              ? 1 / 1_000\n              : size.endsWith('MB')\n                ? 1 / 1_000_000\n                : size.endsWith('GB')\n                  ? 1\n                  : size.endsWith('TB')\n                    ? 1_000\n                    : 0 // Unknown size format\n            return parseFloat(size) * multiplier\n          }\n          return parseSize(a.size) - parseSize(b.size)\n        })\n      }\n    })\n\n    return models\n  }\n\n  private broadcastDownloadProgress(model: string, percent: number) {\n    transmit.broadcast(BROADCAST_CHANNELS.OLLAMA_MODEL_DOWNLOAD, {\n      model,\n      percent,\n      timestamp: new Date().toISOString(),\n    })\n    logger.info(`[OllamaService] Download progress for model \"${model}\": ${percent}%`)\n  }\n\n  private fuseSearchModels(models: NomadOllamaModel[], query: string): NomadOllamaModel[] {\n    const options: IFuseOptions<NomadOllamaModel> = {\n      ignoreDiacritics: true,\n      keys: ['name', 'description', 'tags.name'],\n      threshold: 0.3, // lower threshold for stricter matching\n    }\n\n    const fuse = new Fuse(models, options)\n\n    return fuse.search(query).map(result => result.item)\n  }\n}\n"
  },
  {
    "path": "admin/app/services/queue_service.ts",
    "content": "import { Queue } from 'bullmq'\nimport queueConfig from '#config/queue'\n\nexport class QueueService {\n  private queues: Map<string, Queue> = new Map()\n\n  getQueue(name: string): Queue {\n    if (!this.queues.has(name)) {\n      const queue = new Queue(name, {\n        connection: queueConfig.connection,\n      })\n      this.queues.set(name, queue)\n    }\n    return this.queues.get(name)!\n  }\n\n  async close() {\n    for (const queue of this.queues.values()) {\n      await queue.close()\n    }\n  }\n}\n"
  },
  {
    "path": "admin/app/services/rag_service.ts",
    "content": "import { QdrantClient } from '@qdrant/js-client-rest'\nimport { DockerService } from './docker_service.js'\nimport { inject } from '@adonisjs/core'\nimport logger from '@adonisjs/core/services/logger'\nimport { TokenChunker } from '@chonkiejs/core'\nimport sharp from 'sharp'\nimport { deleteFileIfExists, determineFileType, getFile, getFileStatsIfExists, listDirectoryContentsRecursive, ZIM_STORAGE_PATH } from '../utils/fs.js'\nimport { PDFParse } from 'pdf-parse'\nimport { createWorker } from 'tesseract.js'\nimport { fromBuffer } from 'pdf2pic'\nimport { OllamaService } from './ollama_service.js'\nimport { SERVICE_NAMES } from '../../constants/service_names.js'\nimport { removeStopwords } from 'stopword'\nimport { randomUUID } from 'node:crypto'\nimport { join, resolve, sep } from 'node:path'\nimport KVStore from '#models/kv_store'\nimport { ZIMExtractionService } from './zim_extraction_service.js'\nimport { ZIM_BATCH_SIZE } from '../../constants/zim_extraction.js'\nimport { ProcessAndEmbedFileResponse, ProcessZIMFileResponse, RAGResult, RerankedRAGResult } from '../../types/rag.js'\n\n@inject()\nexport class RagService {\n  private qdrant: QdrantClient | null = null\n  private qdrantInitPromise: Promise<void> | null = null\n  private embeddingModelVerified = false\n  public static UPLOADS_STORAGE_PATH = 'storage/kb_uploads'\n  public static CONTENT_COLLECTION_NAME = 'nomad_knowledge_base'\n  public static EMBEDDING_MODEL = 'nomic-embed-text:v1.5'\n  public static EMBEDDING_DIMENSION = 768 // Nomic Embed Text v1.5 dimension is 768\n  public static MODEL_CONTEXT_LENGTH = 2048 // nomic-embed-text has 2K token context\n  public static MAX_SAFE_TOKENS = 1800 // Leave buffer for prefix and tokenization variance\n  public static TARGET_TOKENS_PER_CHUNK = 1700 // Target 1700 tokens per chunk for embedding\n  public static PREFIX_TOKEN_BUDGET = 10 // Reserve ~10 tokens for prefixes\n  public static CHAR_TO_TOKEN_RATIO = 3 // Approximate chars per token\n  // Nomic Embed Text v1.5 uses task-specific prefixes for optimal performance\n  public static SEARCH_DOCUMENT_PREFIX = 'search_document: '\n  public static SEARCH_QUERY_PREFIX = 'search_query: '\n  public static EMBEDDING_BATCH_SIZE = 8 // Conservative batch size for low-end hardware\n\n  constructor(\n    private dockerService: DockerService,\n    private ollamaService: OllamaService\n  ) { }\n\n  private async _initializeQdrantClient() {\n    if (!this.qdrantInitPromise) {\n      this.qdrantInitPromise = (async () => {\n        const qdrantUrl = await this.dockerService.getServiceURL(SERVICE_NAMES.QDRANT)\n        if (!qdrantUrl) {\n          throw new Error('Qdrant service is not installed or running.')\n        }\n        this.qdrant = new QdrantClient({ url: qdrantUrl })\n      })()\n    }\n    return this.qdrantInitPromise\n  }\n\n  private async _ensureDependencies() {\n    if (!this.qdrant) {\n      await this._initializeQdrantClient()\n    }\n  }\n\n  private async _ensureCollection(\n    collectionName: string,\n    dimensions: number = RagService.EMBEDDING_DIMENSION\n  ) {\n    try {\n      await this._ensureDependencies()\n      const collections = await this.qdrant!.getCollections()\n      const collectionExists = collections.collections.some((col) => col.name === collectionName)\n\n      if (!collectionExists) {\n        await this.qdrant!.createCollection(collectionName, {\n          vectors: {\n            size: dimensions,\n            distance: 'Cosine',\n          },\n        })\n      }\n\n      // Create payload indexes for faster filtering (idempotent — Qdrant ignores duplicates)\n      await this.qdrant!.createPayloadIndex(collectionName, {\n        field_name: 'source',\n        field_schema: 'keyword',\n      })\n      await this.qdrant!.createPayloadIndex(collectionName, {\n        field_name: 'content_type',\n        field_schema: 'keyword',\n      })\n    } catch (error) {\n      logger.error('Error ensuring Qdrant collection:', error)\n      throw error\n    }\n  }\n\n  /**\n   * Sanitizes text to ensure it's safe for JSON encoding and Qdrant storage.\n   * Removes problematic characters that can cause \"unexpected end of hex escape\" errors:\n   * - Null bytes (\\x00)\n   * - Invalid Unicode sequences\n   * - Control characters (except newlines, tabs, and carriage returns)\n   */\n  private sanitizeText(text: string): string {\n    return text\n      // Null bytes\n      .replace(/\\x00/g, '')\n      // Problematic control characters (keep \\n, \\r, \\t)\n      .replace(/[\\x01-\\x08\\x0B-\\x0C\\x0E-\\x1F\\x7F]/g, '')\n      // Invalid Unicode surrogates\n      .replace(/[\\uD800-\\uDFFF]/g, '')\n      // Trim extra whitespace\n      .trim()\n  }\n\n  /**\n   * Estimates token count for text. This is a conservative approximation:\n   * - English text: ~1 token per 3 characters\n   * - Adds buffer for special characters and tokenization variance\n   *\n   * Note: This is approximate and realistic english\n   * tokenization is ~4 chars/token, but we use 3 here to be safe.\n   * Actual tokenization may differ, but being\n   * conservative prevents context length errors.\n   */\n  private estimateTokenCount(text: string): number {\n    // This accounts for special characters, numbers, and punctuation\n    return Math.ceil(text.length / RagService.CHAR_TO_TOKEN_RATIO)\n  }\n\n  /**\n   * Truncates text to fit within token limit, preserving word boundaries.\n   * Ensures the text + prefix won't exceed the model's context window.\n   */\n  private truncateToTokenLimit(text: string, maxTokens: number): string {\n    const estimatedTokens = this.estimateTokenCount(text)\n\n    if (estimatedTokens <= maxTokens) {\n      return text\n    }\n\n    // Calculate how many characters we can keep using our ratio\n    const maxChars = Math.floor(maxTokens * RagService.CHAR_TO_TOKEN_RATIO)\n\n    // Truncate at word boundary\n    let truncated = text.substring(0, maxChars)\n    const lastSpace = truncated.lastIndexOf(' ')\n\n    if (lastSpace > maxChars * 0.8) {\n      // If we found a space in the last 20%, use it\n      truncated = truncated.substring(0, lastSpace)\n    }\n\n    logger.warn(\n      `[RAG] Truncated text from ${text.length} to ${truncated.length} chars (est. ${estimatedTokens} → ${this.estimateTokenCount(truncated)} tokens)`\n    )\n\n    return truncated\n  }\n\n  /**\n   * Preprocesses a query to improve retrieval by expanding it with context.\n   * This helps match documents even when using different terminology.\n   * TODO: We could probably move this to a separate QueryPreprocessor class if it grows more complex, but for now it's manageable here.\n   */\n  private static QUERY_EXPANSION_DICTIONARY: Record<string, string> = {\n    'bob': 'bug out bag',\n    'bov': 'bug out vehicle',\n    'bol': 'bug out location',\n    'edc': 'every day carry',\n    'mre': 'meal ready to eat',\n    'shtf': 'shit hits the fan',\n    'teotwawki': 'the end of the world as we know it',\n    'opsec': 'operational security',\n    'ifak': 'individual first aid kit',\n    'ghb': 'get home bag',\n    'ghi': 'get home in',\n    'wrol': 'without rule of law',\n    'emp': 'electromagnetic pulse',\n    'ham': 'ham amateur radio',\n    'nbr': 'nuclear biological radiological',\n    'cbrn': 'chemical biological radiological nuclear',\n    'sar': 'search and rescue',\n    'comms': 'communications radio',\n    'fifo': 'first in first out',\n    'mylar': 'mylar bag food storage',\n    'paracord': 'paracord 550 cord',\n    'ferro': 'ferro rod fire starter',\n    'bivvy': 'bivvy bivy emergency shelter',\n    'bdu': 'battle dress uniform',\n    'gmrs': 'general mobile radio service',\n    'frs': 'family radio service',\n    'nbc': 'nuclear biological chemical',\n  }\n\n  private preprocessQuery(query: string): string {\n    let expanded = query.trim()\n\n    // Expand known domain abbreviations/acronyms\n    const words = expanded.toLowerCase().split(/\\s+/)\n    const expansions: string[] = []\n\n    for (const word of words) {\n      const cleaned = word.replace(/[^\\w]/g, '')\n      if (RagService.QUERY_EXPANSION_DICTIONARY[cleaned]) {\n        expansions.push(RagService.QUERY_EXPANSION_DICTIONARY[cleaned])\n      }\n    }\n\n    if (expansions.length > 0) {\n      expanded = `${expanded} ${expansions.join(' ')}`\n      logger.debug(`[RAG] Query expanded with domain terms: \"${expanded}\"`)\n    }\n\n    logger.debug(`[RAG] Original query: \"${query}\"`)\n    logger.debug(`[RAG] Preprocessed query: \"${expanded}\"`)\n    return expanded\n  }\n\n  /**\n   * Extract keywords from query for hybrid search\n   */\n  private extractKeywords(query: string): string[] {\n    const split = query.split(' ')\n    const noStopWords = removeStopwords(split)\n\n    // Future: This is basic normalization, could be improved with stemming/lemmatization later\n    const keywords = noStopWords\n      .map((word) => word.replace(/[^\\w]/g, '').toLowerCase())\n      .filter((word) => word.length > 2)\n\n    return [...new Set(keywords)]\n  }\n\n  public async embedAndStoreText(\n    text: string,\n    metadata: Record<string, any> = {},\n    onProgress?: (percent: number) => Promise<void>\n  ): Promise<{ chunks: number } | null> {\n    try {\n      await this._ensureCollection(\n        RagService.CONTENT_COLLECTION_NAME,\n        RagService.EMBEDDING_DIMENSION\n      )\n\n      if (!this.embeddingModelVerified) {\n        const allModels = await this.ollamaService.getModels(true)\n        const embeddingModel = allModels.find((model) => model.name === RagService.EMBEDDING_MODEL)\n\n        if (!embeddingModel) {\n          try {\n            const downloadResult = await this.ollamaService.downloadModel(RagService.EMBEDDING_MODEL)\n            if (!downloadResult.success) {\n              throw new Error(downloadResult.message || 'Unknown error during model download')\n            }\n          } catch (modelError) {\n            logger.error(\n              `[RAG] Embedding model ${RagService.EMBEDDING_MODEL} not found locally and failed to download:`,\n              modelError\n            )\n            this.embeddingModelVerified = false\n            return null\n          }\n        }\n        this.embeddingModelVerified = true\n      }\n\n      // TokenChunker uses character-based tokenization (1 char = 1 token)\n      // We need to convert our embedding model's token counts to character counts\n      // since nomic-embed-text tokenizer uses ~3 chars per token\n      const targetCharsPerChunk = Math.floor(RagService.TARGET_TOKENS_PER_CHUNK * RagService.CHAR_TO_TOKEN_RATIO)\n      const overlapChars = Math.floor(150 * RagService.CHAR_TO_TOKEN_RATIO)\n\n      const chunker = await TokenChunker.create({\n        chunkSize: targetCharsPerChunk,\n        chunkOverlap: overlapChars,\n      })\n\n      const chunkResults = await chunker.chunk(text)\n\n      if (!chunkResults || chunkResults.length === 0) {\n        throw new Error('No text chunks generated for embedding.')\n      }\n\n      // Extract text from chunk results\n      const chunks = chunkResults.map((chunk) => chunk.text)\n\n      const ollamaClient = await this.ollamaService.getClient()\n\n      // Prepare all chunk texts with prefix and truncation\n      const prefixedChunks: string[] = []\n      for (let i = 0; i < chunks.length; i++) {\n        let chunkText = chunks[i]\n\n        // Final safety check: ensure chunk + prefix fits\n        const prefixText = RagService.SEARCH_DOCUMENT_PREFIX\n        const withPrefix = prefixText + chunkText\n        const estimatedTokens = this.estimateTokenCount(withPrefix)\n\n        if (estimatedTokens > RagService.MAX_SAFE_TOKENS) {\n          const prefixTokens = this.estimateTokenCount(prefixText)\n          const maxTokensForText = RagService.MAX_SAFE_TOKENS - prefixTokens\n          logger.warn(\n            `[RAG] Chunk ${i} estimated at ${estimatedTokens} tokens (${chunkText.length} chars), truncating to ${maxTokensForText} tokens`\n          )\n          chunkText = this.truncateToTokenLimit(chunkText, maxTokensForText)\n        }\n\n        prefixedChunks.push(RagService.SEARCH_DOCUMENT_PREFIX + chunkText)\n      }\n\n      // Batch embed chunks for performance\n      const embeddings: number[][] = []\n      const batchSize = RagService.EMBEDDING_BATCH_SIZE\n      const totalBatches = Math.ceil(prefixedChunks.length / batchSize)\n\n      for (let batchIdx = 0; batchIdx < totalBatches; batchIdx++) {\n        const batchStart = batchIdx * batchSize\n        const batch = prefixedChunks.slice(batchStart, batchStart + batchSize)\n\n        logger.debug(`[RAG] Embedding batch ${batchIdx + 1}/${totalBatches} (${batch.length} chunks)`)\n\n        const response = await ollamaClient.embed({\n          model: RagService.EMBEDDING_MODEL,\n          input: batch,\n        })\n\n        embeddings.push(...response.embeddings)\n\n        if (onProgress) {\n          const progress = ((batchStart + batch.length) / prefixedChunks.length) * 100\n          await onProgress(progress)\n        }\n      }\n\n      const timestamp = Date.now()\n      const points = chunks.map((chunkText, index) => {\n        // Sanitize text to prevent JSON encoding errors\n        const sanitizedText = this.sanitizeText(chunkText)\n\n        // Extract keywords from content\n        const contentKeywords = this.extractKeywords(sanitizedText)\n\n        // For ZIM content, also extract keywords from structural metadata\n        let structuralKeywords: string[] = []\n        if (metadata.full_title) {\n          structuralKeywords = this.extractKeywords(metadata.full_title as string)\n        } else if (metadata.article_title) {\n          structuralKeywords = this.extractKeywords(metadata.article_title as string)\n        }\n\n        // Combine and dedup keywords\n        const allKeywords = [...new Set([...structuralKeywords, ...contentKeywords])]\n\n        logger.debug(`[RAG] Extracted keywords for chunk ${index}: [${allKeywords.join(', ')}]`)\n        if (structuralKeywords.length > 0) {\n          logger.debug(`[RAG]   - Structural: [${structuralKeywords.join(', ')}], Content: [${contentKeywords.join(', ')}]`)\n        }\n\n        // Sanitize source metadata as well\n        const sanitizedSource = typeof metadata.source === 'string'\n          ? this.sanitizeText(metadata.source)\n          : 'unknown'\n\n        return {\n          id: randomUUID(), // qdrant requires either uuid or unsigned int\n          vector: embeddings[index],\n          payload: {\n            ...metadata,\n            text: sanitizedText,\n            chunk_index: index,\n            total_chunks: chunks.length,\n            keywords: allKeywords.join(' '), // store as space-separated string for text search\n            char_count: sanitizedText.length,\n            created_at: timestamp,\n            source: sanitizedSource\n          },\n        }\n      })\n\n      await this.qdrant!.upsert(RagService.CONTENT_COLLECTION_NAME, { points })\n\n      logger.debug(`[RAG] Successfully embedded and stored ${chunks.length} chunks`)\n      logger.debug(`[RAG] First chunk preview: \"${chunks[0].substring(0, 100)}...\"`)\n\n      return { chunks: chunks.length }\n    } catch (error) {\n      console.error(error)\n      logger.error('[RAG] Error embedding text:', error)\n      return null\n    }\n  }\n\n  private async preprocessImage(filebuffer: Buffer): Promise<Buffer> {\n    return await sharp(filebuffer)\n      .grayscale()\n      .normalize()\n      .sharpen()\n      .resize({ width: 2000, fit: 'inside' })\n      .toBuffer()\n  }\n\n  private async convertPDFtoImages(filebuffer: Buffer): Promise<Buffer[]> {\n    const converted = await fromBuffer(filebuffer, {\n      quality: 50,\n      density: 200,\n      format: 'png',\n    }).bulk(-1, {\n      responseType: 'buffer',\n    })\n    return converted.filter((res) => res.buffer).map((res) => res.buffer!)\n  }\n\n  private async extractPDFText(filebuffer: Buffer): Promise<string> {\n    const parser = new PDFParse({ data: filebuffer })\n    const data = await parser.getText()\n    await parser.destroy()\n    return data.text\n  }\n\n  private async extractTXTText(filebuffer: Buffer): Promise<string> {\n    return filebuffer.toString('utf-8')\n  }\n\n  private async extractImageText(filebuffer: Buffer): Promise<string> {\n    const worker = await createWorker('eng')\n    const result = await worker.recognize(filebuffer)\n    await worker.terminate()\n    return result.data.text\n  }\n\n  private async processImageFile(fileBuffer: Buffer): Promise<string> {\n    const preprocessedBuffer = await this.preprocessImage(fileBuffer)\n    return await this.extractImageText(preprocessedBuffer)\n  }\n\n  /**\n   * Will process the PDF and attempt to extract text.\n   * If the extracted text is minimal, it will fallback to OCR on each page.\n   */\n  private async processPDFFile(fileBuffer: Buffer): Promise<string> {\n    let extractedText = await this.extractPDFText(fileBuffer)\n\n    // Check if there was no extracted text or it was very minimal\n    if (!extractedText || extractedText.trim().length < 100) {\n      logger.debug('[RAG] PDF text extraction minimal, attempting OCR on pages')\n      // Convert PDF pages to images for OCR if text extraction was poor\n      const imageBuffers = await this.convertPDFtoImages(fileBuffer)\n      extractedText = ''\n\n      for (const imgBuffer of imageBuffers) {\n        const preprocessedImg = await this.preprocessImage(imgBuffer)\n        const pageText = await this.extractImageText(preprocessedImg)\n        extractedText += pageText + '\\n'\n      }\n    }\n\n    return extractedText\n  }\n\n  /**\n   * Process a ZIM file: extract content with metadata and embed each chunk.\n   * Returns early with complete result since ZIM processing is self-contained.\n   * Supports batch processing to prevent lock timeouts on large ZIM files.\n   */\n  private async processZIMFile(\n    filepath: string,\n    deleteAfterEmbedding: boolean,\n    batchOffset?: number,\n    onProgress?: (percent: number) => Promise<void>\n  ): Promise<ProcessZIMFileResponse> {\n    const zimExtractionService = new ZIMExtractionService()\n\n    // Process in batches to avoid lock timeout\n    const startOffset = batchOffset || 0\n\n    logger.info(\n      `[RAG] Extracting ZIM content (batch: offset=${startOffset}, size=${ZIM_BATCH_SIZE})`\n    )\n\n    const zimChunks = await zimExtractionService.extractZIMContent(filepath, {\n      startOffset,\n      batchSize: ZIM_BATCH_SIZE,\n    })\n\n    logger.info(\n      `[RAG] Extracted ${zimChunks.length} chunks from ZIM file with enhanced metadata`\n    )\n\n    // Process each chunk individually with its metadata\n    let totalChunks = 0\n    for (let i = 0; i < zimChunks.length; i++) {\n      const zimChunk = zimChunks[i]\n      const result = await this.embedAndStoreText(zimChunk.text, {\n        source: filepath,\n        content_type: 'zim_article',\n\n        // Article-level context\n        article_title: zimChunk.articleTitle,\n        article_path: zimChunk.articlePath,\n\n        // Section-level context\n        section_title: zimChunk.sectionTitle,\n        full_title: zimChunk.fullTitle,\n        hierarchy: zimChunk.hierarchy,\n        section_level: zimChunk.sectionLevel,\n\n        // Use the same document ID for all chunks from the same article for grouping in search results\n        document_id: zimChunk.documentId,\n\n        // Archive metadata\n        archive_title: zimChunk.archiveMetadata.title,\n        archive_creator: zimChunk.archiveMetadata.creator,\n        archive_publisher: zimChunk.archiveMetadata.publisher,\n        archive_date: zimChunk.archiveMetadata.date,\n        archive_language: zimChunk.archiveMetadata.language,\n        archive_description: zimChunk.archiveMetadata.description,\n\n        // Extraction metadata - not overly relevant for search, but could be useful for debugging and future features...\n        extraction_strategy: zimChunk.strategy,\n      })\n\n      if (result) {\n        totalChunks += result.chunks\n      }\n\n      if (onProgress) {\n        await onProgress(((i + 1) / zimChunks.length) * 100)\n      }\n    }\n\n    // Count unique articles processed in this batch\n    const articlesInBatch = new Set(zimChunks.map((c) => c.documentId)).size\n    const hasMoreBatches = zimChunks.length === ZIM_BATCH_SIZE\n\n    logger.info(\n      `[RAG] Successfully embedded ${totalChunks} total chunks from ${articlesInBatch} articles (hasMore: ${hasMoreBatches})`\n    )\n\n    // Only delete the file when:\n    // 1. deleteAfterEmbedding is true (caller wants deletion)\n    // 2. No more batches remain (this is the final batch)\n    // This prevents race conditions where early batches complete after later ones\n    const shouldDelete = deleteAfterEmbedding && !hasMoreBatches\n    if (shouldDelete) {\n      logger.info(`[RAG] Final batch complete, deleting ZIM file: ${filepath}`)\n      await deleteFileIfExists(filepath)\n    } else if (!hasMoreBatches) {\n      logger.info(`[RAG] Final batch complete, but file deletion was not requested`)\n    }\n\n    return {\n      success: true,\n      message: hasMoreBatches\n        ? 'ZIM batch processed successfully. More batches remain.'\n        : 'ZIM file processed and embedded successfully with enhanced metadata.',\n      chunks: totalChunks,\n      hasMoreBatches,\n      articlesProcessed: articlesInBatch,\n    }\n  }\n\n  private async processTextFile(fileBuffer: Buffer): Promise<string> {\n    return await this.extractTXTText(fileBuffer)\n  }\n\n  private async embedTextAndCleanup(\n    extractedText: string,\n    filepath: string,\n    deleteAfterEmbedding: boolean = false,\n    onProgress?: (percent: number) => Promise<void>\n  ): Promise<{ success: boolean; message: string; chunks?: number }> {\n    if (!extractedText || extractedText.trim().length === 0) {\n      return { success: false, message: 'Process completed succesfully, but no text was found to embed.' }\n    }\n\n    const embedResult = await this.embedAndStoreText(extractedText, {\n      source: filepath\n    }, onProgress)\n\n    if (!embedResult) {\n      return { success: false, message: 'Failed to embed and store the extracted text.' }\n    }\n\n    if (deleteAfterEmbedding) {\n      logger.info(`[RAG] Embedding complete, deleting uploaded file: ${filepath}`)\n      await deleteFileIfExists(filepath)\n    }\n\n    return {\n      success: true,\n      message: 'File processed and embedded successfully.',\n      chunks: embedResult.chunks,\n    }\n  }\n\n  /**\n   * Main pipeline to process and embed an uploaded file into the RAG knowledge base.\n   * This includes text extraction, chunking, embedding, and storing in Qdrant.\n   * \n   * Orchestrates file type detection and delegates to specialized processors.\n   * For ZIM files, supports batch processing via batchOffset parameter.\n   */\n  public async processAndEmbedFile(\n    filepath: string,\n    deleteAfterEmbedding: boolean = false,\n    batchOffset?: number,\n    onProgress?: (percent: number) => Promise<void>\n  ): Promise<ProcessAndEmbedFileResponse> {\n    try {\n      const fileType = determineFileType(filepath)\n      logger.debug(`[RAG] Processing file: ${filepath} (detected type: ${fileType})`)\n\n      if (fileType === 'unknown') {\n        return { success: false, message: 'Unsupported file type.' }\n      }\n\n      // Read file buffer (not needed for ZIM as it reads directly)\n      const fileBuffer = fileType !== 'zim' ? await getFile(filepath, 'buffer') : null\n      if (fileType !== 'zim' && !fileBuffer) {\n        return { success: false, message: 'Failed to read the uploaded file.' }\n      }\n\n      // Process based on file type\n      // ZIM files are handled specially since they have their own embedding workflow\n      if (fileType === 'zim') {\n        return await this.processZIMFile(filepath, deleteAfterEmbedding, batchOffset, onProgress)\n      }\n\n      // Extract text based on file type\n      // Report ~10% when extraction begins; actual embedding progress follows via callback\n      if (onProgress) await onProgress(10)\n      let extractedText: string\n      switch (fileType) {\n        case 'image':\n          extractedText = await this.processImageFile(fileBuffer!)\n          break\n        case 'pdf':\n          extractedText = await this.processPDFFile(fileBuffer!)\n          break\n        case 'text':\n        default:\n          extractedText = await this.processTextFile(fileBuffer!)\n          break\n      }\n\n      // Extraction done — scale remaining embedding progress from 15% to 100%\n      if (onProgress) await onProgress(15)\n      const scaledProgress = onProgress\n        ? (p: number) => onProgress(15 + p * 0.85)\n        : undefined\n\n      // Embed extracted text and cleanup\n      return await this.embedTextAndCleanup(extractedText, filepath, deleteAfterEmbedding, scaledProgress)\n    } catch (error) {\n      logger.error('[RAG] Error processing and embedding file:', error)\n      return { success: false, message: 'Error processing and embedding file.' }\n    }\n  }\n\n  /**\n   * Search for documents similar to the query text in the Qdrant knowledge base.\n   * Uses a hybrid approach combining semantic similarity and keyword matching.\n   * Implements adaptive thresholds and result reranking for optimal retrieval.\n   * @param query - The search query text\n   * @param limit - Maximum number of results to return (default: 5)\n   * @param scoreThreshold - Minimum similarity score threshold (default: 0.3, much lower than before)\n   * @returns Array of relevant text chunks with their scores\n   */\n  public async searchSimilarDocuments(\n    query: string,\n    limit: number = 5,\n    scoreThreshold: number = 0.3 // Lower default threshold - was 0.7, now 0.3\n  ): Promise<Array<{ text: string; score: number; metadata?: Record<string, any> }>> {\n    try {\n      logger.debug(`[RAG] Starting similarity search for query: \"${query}\"`)\n\n      await this._ensureCollection(\n        RagService.CONTENT_COLLECTION_NAME,\n        RagService.EMBEDDING_DIMENSION\n      )\n\n      // Check if collection has any points\n      const collectionInfo = await this.qdrant!.getCollection(RagService.CONTENT_COLLECTION_NAME)\n      const pointCount = collectionInfo.points_count || 0\n      logger.debug(`[RAG] Knowledge base contains ${pointCount} document chunks`)\n\n      if (pointCount === 0) {\n        logger.debug('[RAG] Knowledge base is empty. Could not perform search.')\n        return []\n      }\n\n      if (!this.embeddingModelVerified) {\n        const allModels = await this.ollamaService.getModels(true)\n        const embeddingModel = allModels.find((model) => model.name === RagService.EMBEDDING_MODEL)\n\n        if (!embeddingModel) {\n          logger.warn(\n            `[RAG] ${RagService.EMBEDDING_MODEL} not found. Cannot perform similarity search.`\n          )\n          this.embeddingModelVerified = false\n          return []\n        }\n        this.embeddingModelVerified = true\n      }\n\n      // Preprocess query for better matching\n      const processedQuery = this.preprocessQuery(query)\n      const keywords = this.extractKeywords(processedQuery)\n      logger.debug(`[RAG] Extracted keywords: [${keywords.join(', ')}]`)\n\n      // Generate embedding for the query with search_query prefix\n      const ollamaClient = await this.ollamaService.getClient()\n\n      // Ensure query doesn't exceed token limit\n      const prefixTokens = this.estimateTokenCount(RagService.SEARCH_QUERY_PREFIX)\n      const maxQueryTokens = RagService.MAX_SAFE_TOKENS - prefixTokens\n      const truncatedQuery = this.truncateToTokenLimit(processedQuery, maxQueryTokens)\n\n      const prefixedQuery = RagService.SEARCH_QUERY_PREFIX + truncatedQuery\n      logger.debug(`[RAG] Generating embedding with prefix: \"${RagService.SEARCH_QUERY_PREFIX}\"`)\n\n      // Validate final token count\n      const queryTokenCount = this.estimateTokenCount(prefixedQuery)\n      if (queryTokenCount > RagService.MAX_SAFE_TOKENS) {\n        logger.error(\n          `[RAG] Query too long even after truncation: ${queryTokenCount} tokens (max: ${RagService.MAX_SAFE_TOKENS})`\n        )\n        return []\n      }\n\n      const response = await ollamaClient.embed({\n        model: RagService.EMBEDDING_MODEL,\n        input: [prefixedQuery],\n      })\n\n      // Perform semantic search with a higher limit to enable reranking\n      const searchLimit = limit * 3 // Get more results for reranking\n      logger.debug(\n        `[RAG] Searching for top ${searchLimit} semantic matches (threshold: ${scoreThreshold})`\n      )\n\n      const searchResults = await this.qdrant!.search(RagService.CONTENT_COLLECTION_NAME, {\n        vector: response.embeddings[0],\n        limit: searchLimit,\n        score_threshold: scoreThreshold,\n        with_payload: true,\n      })\n\n      logger.debug(`[RAG] Found ${searchResults.length} results above threshold ${scoreThreshold}`)\n\n      // Map results with metadata for reranking\n      const resultsWithMetadata: RAGResult[] = searchResults.map((result) => ({\n        text: (result.payload?.text as string) || '',\n        score: result.score,\n        keywords: (result.payload?.keywords as string) || '',\n        chunk_index: (result.payload?.chunk_index as number) || 0,\n        created_at: (result.payload?.created_at as number) || 0,\n        // Enhanced ZIM metadata (likely be undefined for non-ZIM content)\n        article_title: result.payload?.article_title as string | undefined,\n        section_title: result.payload?.section_title as string | undefined,\n        full_title: result.payload?.full_title as string | undefined,\n        hierarchy: result.payload?.hierarchy as string | undefined,\n        document_id: result.payload?.document_id as string | undefined,\n        content_type: result.payload?.content_type as string | undefined,\n        source: result.payload?.source as string | undefined,\n      }))\n\n      const rerankedResults = this.rerankResults(resultsWithMetadata, keywords, query)\n\n      logger.debug(`[RAG] Top 3 results after reranking:`)\n      rerankedResults.slice(0, 3).forEach((result, idx) => {\n        logger.debug(\n          `[RAG]   ${idx + 1}. Score: ${result.finalScore.toFixed(4)} (semantic: ${result.score.toFixed(4)}) - \"${result.text.substring(0, 100)}...\"`\n        )\n      })\n\n      // Apply source diversity penalty to avoid all results from the same document\n      const diverseResults = this.applySourceDiversity(rerankedResults)\n\n      // Return top N results with enhanced metadata\n      return diverseResults.slice(0, limit).map((result) => ({\n        text: result.text,\n        score: result.finalScore,\n        metadata: {\n          chunk_index: result.chunk_index,\n          created_at: result.created_at,\n          semantic_score: result.score,\n          // Enhanced ZIM metadata (likely be undefined for non-ZIM content)\n          article_title: result.article_title,\n          section_title: result.section_title,\n          full_title: result.full_title,\n          hierarchy: result.hierarchy,\n          document_id: result.document_id,\n          content_type: result.content_type,\n        },\n      }))\n    } catch (error) {\n      logger.error('[RAG] Error searching similar documents:', error)\n      return []\n    }\n  }\n\n  /**\n   * Rerank search results using hybrid scoring that combines:\n   * 1. Semantic similarity score (primary signal)\n   * 2. Keyword overlap bonus (conservative, quality-gated)\n   * 3. Direct term matches (conservative)\n   *\n   * Tries to boost only already-relevant results, not promote\n   * low-quality results just because they have keyword matches.\n   *\n   * Future: this is a decent feature-based approach, but we could\n   * switch to a python-based reranker in the future if the benefits\n   * outweigh the overhead.\n   */\n  private rerankResults(\n    results: Array<RAGResult>,\n    queryKeywords: string[],\n    originalQuery: string\n  ): Array<RerankedRAGResult> {\n    return results\n      .map((result) => {\n        let finalScore = result.score\n\n        // Quality gate: Only apply boosts if semantic score is reasonable\n        // Try to prevent promoting irrelevant results that just happen to have keyword matches\n        const MIN_SEMANTIC_THRESHOLD = 0.35\n\n        if (result.score < MIN_SEMANTIC_THRESHOLD) {\n          // For low-scoring results, use semantic score as-is\n          // This prevents false positives from keyword gaming\n          logger.debug(\n            `[RAG] Skipping boost for low semantic score: ${result.score.toFixed(3)} (threshold: ${MIN_SEMANTIC_THRESHOLD})`\n          )\n          return {\n            ...result,\n            finalScore,\n          }\n        }\n\n        // Boost score based on keyword overlap (diminishing returns - overlap goes down, so does boost)\n        const docKeywords = result.keywords\n          .toLowerCase()\n          .split(' ')\n          .filter((k) => k.length > 0)\n        const matchingKeywords = queryKeywords.filter(\n          (kw) =>\n            docKeywords.includes(kw.toLowerCase()) ||\n            result.text.toLowerCase().includes(kw.toLowerCase())\n        )\n        const keywordOverlap = matchingKeywords.length / Math.max(queryKeywords.length, 1)\n\n        // Use square root for diminishing returns: 100% overlap = sqrt(1.0) = 1.0, 25% = 0.5\n        // Then scale conservatively (max 10% boost instead of 20%)\n        const keywordBoost = Math.sqrt(keywordOverlap) * 0.1 * result.score\n\n        if (keywordOverlap > 0) {\n          logger.debug(\n            `[RAG] Keyword overlap: ${matchingKeywords.length}/${queryKeywords.length} - Boost: ${keywordBoost.toFixed(3)}`\n          )\n        }\n\n        // Boost if original query terms appear in text (case-insensitive)\n        // Scale boost proportionally to base score to avoid over-promoting weak matches\n        const queryTerms = originalQuery\n          .toLowerCase()\n          .split(/\\s+/)\n          .filter((t) => t.length > 3)\n        const directMatches = queryTerms.filter((term) =>\n          result.text.toLowerCase().includes(term)\n        ).length\n\n        if (queryTerms.length > 0) {\n          const directMatchRatio = directMatches / queryTerms.length\n          // Conservative boost: max 7.5% of the base score\n          const directMatchBoost = Math.sqrt(directMatchRatio) * 0.075 * result.score\n\n          if (directMatches > 0) {\n            logger.debug(\n              `[RAG] Direct term matches: ${directMatches}/${queryTerms.length} - Boost: ${directMatchBoost.toFixed(3)}`\n            )\n            finalScore += directMatchBoost\n          }\n        }\n\n        finalScore = Math.min(1.0, finalScore + keywordBoost)\n\n        return {\n          ...result,\n          finalScore,\n        }\n      })\n      .sort((a, b) => b.finalScore - a.finalScore)\n  }\n\n  /**\n   * Applies a diversity penalty so results from the same source are down-weighted.\n   * Uses greedy selection: for each result, apply 0.85^n penalty where n is the\n   * number of results already selected from the same source.\n   */\n  private applySourceDiversity(\n    results: Array<RerankedRAGResult>\n  ) {\n    const sourceCounts = new Map<string, number>()\n    const DIVERSITY_PENALTY = 0.85\n\n    return results\n      .map((result) => {\n        const sourceKey = result.document_id || result.source || 'unknown'\n        const count = sourceCounts.get(sourceKey) || 0\n        const penalty = Math.pow(DIVERSITY_PENALTY, count)\n        const diverseScore = result.finalScore * penalty\n\n        sourceCounts.set(sourceKey, count + 1)\n\n        if (count > 0) {\n          logger.debug(\n            `[RAG] Source diversity penalty for \"${sourceKey}\": ${result.finalScore.toFixed(4)} → ${diverseScore.toFixed(4)} (seen ${count}x)`\n          )\n        }\n\n        return { ...result, finalScore: diverseScore }\n      })\n      .sort((a, b) => b.finalScore - a.finalScore)\n  }\n\n  /**\n   * Retrieve all unique source files that have been stored in the knowledge base.\n   * @returns Array of unique full source paths\n   */\n  public async getStoredFiles(): Promise<string[]> {\n    try {\n      await this._ensureCollection(\n        RagService.CONTENT_COLLECTION_NAME,\n        RagService.EMBEDDING_DIMENSION\n      )\n\n      const sources = new Set<string>()\n      let offset: string | number | null | Record<string, unknown> = null\n      const batchSize = 100\n\n      // Scroll through all points in the collection (only fetch source field)\n      do {\n        const scrollResult = await this.qdrant!.scroll(RagService.CONTENT_COLLECTION_NAME, {\n          limit: batchSize,\n          offset: offset,\n          with_payload: ['source'],\n          with_vector: false,\n        })\n\n        // Extract unique source values from payloads\n        scrollResult.points.forEach((point) => {\n          const source = point.payload?.source\n          if (source && typeof source === 'string') {\n            sources.add(source)\n          }\n        })\n\n        offset = scrollResult.next_page_offset || null\n      } while (offset !== null)\n\n      return Array.from(sources)\n    } catch (error) {\n      logger.error('Error retrieving stored files:', error)\n      return []\n    }\n  }\n\n  /**\n   * Delete all Qdrant points associated with a given source path and remove\n   * the corresponding file from disk if it lives under the uploads directory.\n   * @param source - Full source path as stored in Qdrant payloads\n   */\n  public async deleteFileBySource(source: string): Promise<{ success: boolean; message: string }> {\n    try {\n      await this._ensureCollection(\n        RagService.CONTENT_COLLECTION_NAME,\n        RagService.EMBEDDING_DIMENSION\n      )\n\n      await this.qdrant!.delete(RagService.CONTENT_COLLECTION_NAME, {\n        filter: {\n          must: [{ key: 'source', match: { value: source } }],\n        },\n      })\n\n      logger.info(`[RAG] Deleted all points for source: ${source}`)\n\n      /** Delete the physical file only if it lives inside the uploads directory.\n      * resolve() normalises path traversal sequences (e.g. \"/../..\") before the\n      * check to prevent path traversal vulns\n      * The trailing sep is to ensure a prefix like \"kb_uploads_{something_incorrect}\" can't slip through.\n      */\n      const uploadsAbsPath = join(process.cwd(), RagService.UPLOADS_STORAGE_PATH)\n      const resolvedSource = resolve(source)\n      if (resolvedSource.startsWith(uploadsAbsPath + sep)) {\n        await deleteFileIfExists(resolvedSource)\n        logger.info(`[RAG] Deleted uploaded file from disk: ${resolvedSource}`)\n      } else {\n        logger.warn(`[RAG] File was removed from knowledge base but doesn't live in Nomad's uploads directory, so it can't be safely removed. Skipping deletion of physical file...`)\n      }\n\n      return { success: true, message: 'File removed from knowledge base.' }\n    } catch (error) {\n      logger.error('[RAG] Error deleting file from knowledge base:', error)\n      return { success: false, message: 'Error deleting file from knowledge base.' }\n    }\n  }\n\n  public async discoverNomadDocs(force?: boolean): Promise<{ success: boolean; message: string }> {\n    try {\n      const README_PATH = join(process.cwd(), 'README.md')\n      const DOCS_DIR = join(process.cwd(), 'docs')\n\n      const alreadyEmbeddedRaw = await KVStore.getValue('rag.docsEmbedded')\n      if (alreadyEmbeddedRaw && !force) {\n        logger.info('[RAG] Nomad docs have already been discovered and queued. Skipping.')\n        return { success: true, message: 'Nomad docs have already been discovered and queued. Skipping.' }\n      }\n\n      const filesToEmbed: Array<{ path: string; source: string }> = []\n\n      const readmeExists = await getFileStatsIfExists(README_PATH)\n      if (readmeExists) {\n        filesToEmbed.push({ path: README_PATH, source: 'README.md' })\n      }\n\n      const dirContents = await listDirectoryContentsRecursive(DOCS_DIR)\n      for (const entry of dirContents) {\n        if (entry.type === 'file') {\n          filesToEmbed.push({ path: entry.key, source: join('docs', entry.name) })\n        }\n      }\n\n      logger.info(`[RAG] Discovered ${filesToEmbed.length} Nomad doc files to embed`)\n\n      // Import EmbedFileJob dynamically to avoid circular dependencies\n      const { EmbedFileJob } = await import('#jobs/embed_file_job')\n\n      // Dispatch an EmbedFileJob for each discovered file\n      for (const fileInfo of filesToEmbed) {\n        try {\n          logger.info(`[RAG] Dispatching embed job for: ${fileInfo.source}`)\n          await EmbedFileJob.dispatch({\n            filePath: fileInfo.path,\n            fileName: fileInfo.source,\n          })\n          logger.info(`[RAG] Successfully dispatched job for ${fileInfo.source}`)\n        } catch (fileError) {\n          logger.error(\n            `[RAG] Error dispatching job for file ${fileInfo.source}:`,\n            fileError\n          )\n        }\n      }\n\n      // Update KV store to mark docs as discovered so we don't redo this unnecessarily\n      await KVStore.setValue('rag.docsEmbedded', true)\n\n      return { success: true, message: `Nomad docs discovery completed. Dispatched ${filesToEmbed.length} embedding jobs.` }\n    } catch (error) {\n      logger.error('Error discovering Nomad docs:', error)\n      return { success: false, message: 'Error discovering Nomad docs.' }\n    }\n  }\n\n  /**\n   * Scans the knowledge base storage directories and syncs with Qdrant.\n   * Identifies files that exist in storage but haven't been embedded yet,\n   * and dispatches EmbedFileJob for each missing file.\n   *\n   * @returns Object containing success status, message, and counts of scanned/queued files\n   */\n  public async scanAndSyncStorage(): Promise<{\n    success: boolean\n    message: string\n    filesScanned?: number\n    filesQueued?: number\n  }> {\n    try {\n      logger.info('[RAG] Starting knowledge base sync scan')\n\n      const KB_UPLOADS_PATH = join(process.cwd(), RagService.UPLOADS_STORAGE_PATH)\n      const ZIM_PATH = join(process.cwd(), ZIM_STORAGE_PATH)\n\n      const filesInStorage: string[] = []\n\n      // Force resync of Nomad docs\n      await this.discoverNomadDocs(true).catch((error) => {\n        logger.error('[RAG] Error during Nomad docs discovery in sync process:', error)\n      })\n\n      // Scan kb_uploads directory\n      try {\n        const kbContents = await listDirectoryContentsRecursive(KB_UPLOADS_PATH)\n        kbContents.forEach((entry) => {\n          if (entry.type === 'file') {\n            filesInStorage.push(entry.key)\n          }\n        })\n        logger.debug(`[RAG] Found ${kbContents.length} files in ${RagService.UPLOADS_STORAGE_PATH}`)\n      } catch (error) {\n        if (error.code === 'ENOENT') {\n          logger.debug(`[RAG] ${RagService.UPLOADS_STORAGE_PATH} directory does not exist, skipping`)\n        } else {\n          throw error\n        }\n      }\n\n      // Scan zim directory\n      try {\n        const zimContents = await listDirectoryContentsRecursive(ZIM_PATH)\n        zimContents.forEach((entry) => {\n          if (entry.type === 'file') {\n            filesInStorage.push(entry.key)\n          }\n        })\n        logger.debug(`[RAG] Found ${zimContents.length} files in ${ZIM_STORAGE_PATH}`)\n      } catch (error) {\n        if (error.code === 'ENOENT') {\n          logger.debug(`[RAG] ${ZIM_STORAGE_PATH} directory does not exist, skipping`)\n        } else {\n          throw error\n        }\n      }\n\n      logger.info(`[RAG] Found ${filesInStorage.length} total files in storage directories`)\n\n      // Get all stored sources from Qdrant\n      await this._ensureCollection(\n        RagService.CONTENT_COLLECTION_NAME,\n        RagService.EMBEDDING_DIMENSION\n      )\n\n      const sourcesInQdrant = new Set<string>()\n      let offset: string | number | null | Record<string, unknown> = null\n      const batchSize = 100\n\n      // Scroll through all points to get sources\n      do {\n        const scrollResult = await this.qdrant!.scroll(RagService.CONTENT_COLLECTION_NAME, {\n          limit: batchSize,\n          offset: offset,\n          with_payload: ['source'], // Only fetch source field for efficiency\n          with_vector: false,\n        })\n\n        scrollResult.points.forEach((point) => {\n          const source = point.payload?.source\n          if (source && typeof source === 'string') {\n            sourcesInQdrant.add(source)\n          }\n        })\n\n        offset = scrollResult.next_page_offset || null\n      } while (offset !== null)\n\n      logger.info(`[RAG] Found ${sourcesInQdrant.size} unique sources in Qdrant`)\n\n      // Find files that are in storage but not in Qdrant\n      const filesToEmbed = filesInStorage.filter((filePath) => !sourcesInQdrant.has(filePath))\n\n      logger.info(`[RAG] Found ${filesToEmbed.length} files that need embedding`)\n\n      if (filesToEmbed.length === 0) {\n        return {\n          success: true,\n          message: 'Knowledge base is already in sync',\n          filesScanned: filesInStorage.length,\n          filesQueued: 0,\n        }\n      }\n\n      // Import EmbedFileJob dynamically to avoid circular dependencies\n      const { EmbedFileJob } = await import('#jobs/embed_file_job')\n\n      // Dispatch jobs for files that need embedding\n      let queuedCount = 0\n      for (const filePath of filesToEmbed) {\n        try {\n          const fileName = filePath.split(/[/\\\\]/).pop() || filePath\n          const stats = await getFileStatsIfExists(filePath)\n\n          logger.info(`[RAG] Dispatching embed job for: ${fileName}`)\n          await EmbedFileJob.dispatch({\n            filePath: filePath,\n            fileName: fileName,\n            fileSize: stats?.size,\n          })\n          queuedCount++\n          logger.debug(`[RAG] Successfully dispatched job for ${fileName}`)\n        } catch (fileError) {\n          logger.error(`[RAG] Error dispatching job for file ${filePath}:`, fileError)\n        }\n      }\n\n      return {\n        success: true,\n        message: `Scanned ${filesInStorage.length} files, queued ${queuedCount} for embedding`,\n        filesScanned: filesInStorage.length,\n        filesQueued: queuedCount,\n      }\n    } catch (error) {\n      logger.error('[RAG] Error scanning and syncing knowledge base:', error)\n      return {\n        success: false,\n        message: 'Error scanning and syncing knowledge base',\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "admin/app/services/system_service.ts",
    "content": "import Service from '#models/service'\nimport { inject } from '@adonisjs/core'\nimport { DockerService } from '#services/docker_service'\nimport { ServiceSlim } from '../../types/services.js'\nimport logger from '@adonisjs/core/services/logger'\nimport si from 'systeminformation'\nimport { GpuHealthStatus, NomadDiskInfo, NomadDiskInfoRaw, SystemInformationResponse } from '../../types/system.js'\nimport { SERVICE_NAMES } from '../../constants/service_names.js'\nimport { readFileSync } from 'fs'\nimport path, { join } from 'path'\nimport { getAllFilesystems, getFile } from '../utils/fs.js'\nimport axios from 'axios'\nimport env from '#start/env'\nimport KVStore from '#models/kv_store'\nimport { KV_STORE_SCHEMA, KVStoreKey } from '../../types/kv_store.js'\nimport { isNewerVersion } from '../utils/version.js'\n\n\n@inject()\nexport class SystemService {\n  private static appVersion: string | null = null\n  private static diskInfoFile = '/storage/nomad-disk-info.json'\n\n  constructor(private dockerService: DockerService) { }\n\n  async checkServiceInstalled(serviceName: string): Promise<boolean> {\n    const services = await this.getServices({ installedOnly: true });\n    return services.some(service => service.service_name === serviceName);\n  }\n\n  async getInternetStatus(): Promise<boolean> {\n    const DEFAULT_TEST_URL = 'https://1.1.1.1/cdn-cgi/trace'\n    const MAX_ATTEMPTS = 3\n\n    let testUrl = DEFAULT_TEST_URL\n    let customTestUrl = env.get('INTERNET_STATUS_TEST_URL')?.trim()\n\n    // check that customTestUrl is a valid URL, if provided\n    if (customTestUrl && customTestUrl !== '') {\n      try {\n        new URL(customTestUrl)\n        testUrl = customTestUrl\n      } catch (error) {\n        logger.warn(\n          `Invalid INTERNET_STATUS_TEST_URL: ${customTestUrl}. Falling back to default URL.`\n        )\n      }\n    }\n\n    for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {\n      try {\n        const res = await axios.get(testUrl, { timeout: 5000 })\n        return res.status === 200\n      } catch (error) {\n        logger.warn(\n          `Internet status check attempt ${attempt}/${MAX_ATTEMPTS} failed: ${error instanceof Error ? error.message : error}`\n        )\n\n        if (attempt < MAX_ATTEMPTS) {\n          // delay before next attempt\n          await new Promise((resolve) => setTimeout(resolve, 1000))\n        }\n      }\n    }\n\n    logger.warn('All internet status check attempts failed.')\n    return false\n  }\n\n  async getNvidiaSmiInfo(): Promise<Array<{ vendor: string; model: string; vram: number; }> | { error: string } | 'OLLAMA_NOT_FOUND' | 'BAD_RESPONSE' | 'UNKNOWN_ERROR'> {\n    try {\n      const containers = await this.dockerService.docker.listContainers({ all: false })\n      const ollamaContainer = containers.find((c) =>\n        c.Names.includes(`/${SERVICE_NAMES.OLLAMA}`)\n      )\n      if (!ollamaContainer) {\n        logger.info('Ollama container not found for nvidia-smi info retrieval. This is expected if Ollama is not installed.')\n        return 'OLLAMA_NOT_FOUND'\n      }\n\n      // Execute nvidia-smi inside the Ollama container to get GPU info\n      const container = this.dockerService.docker.getContainer(ollamaContainer.Id)\n      const exec = await container.exec({\n        Cmd: ['nvidia-smi', '--query-gpu=name,memory.total', '--format=csv,noheader,nounits'],\n        AttachStdout: true,\n        AttachStderr: true,\n        Tty: true,\n      })\n\n      // Read the output stream with a timeout to prevent hanging if nvidia-smi fails\n      const stream = await exec.start({ Tty: true })\n      const output = await new Promise<string>((resolve) => {\n        let data = ''\n        const timeout = setTimeout(() => resolve(data), 5000)\n        stream.on('data', (chunk: Buffer) => { data += chunk.toString() })\n        stream.on('end', () => { clearTimeout(timeout); resolve(data) })\n      })\n\n      // Remove any non-printable characters and trim the output\n      const cleaned = output.replace(/[\\x00-\\x08]/g, '').trim()\n      if (cleaned && !cleaned.toLowerCase().includes('error') && !cleaned.toLowerCase().includes('not found')) {\n        // Split by newlines to handle multiple GPUs installed\n        const lines = cleaned.split('\\n').filter(line => line.trim())\n\n        // Map each line out to a useful structure for us\n        const gpus = lines.map(line => {\n          const parts = line.split(',').map((s) => s.trim())\n          return {\n            vendor: 'NVIDIA',\n            model: parts[0] || 'NVIDIA GPU',\n            vram: parts[1] ? parseInt(parts[1], 10) : 0,\n          }\n        })\n\n        return gpus.length > 0 ? gpus : 'BAD_RESPONSE'\n      }\n\n      // If we got output but looks like an error, consider it a bad response from nvidia-smi\n      return 'BAD_RESPONSE'\n    }\n    catch (error) {\n      logger.error('Error getting nvidia-smi info:', error)\n      if (error instanceof Error && error.message) {\n        return { error: error.message }\n      }\n      return 'UNKNOWN_ERROR'\n    }\n  }\n\n  async getServices({ installedOnly = true }: { installedOnly?: boolean }): Promise<ServiceSlim[]> {\n    await this._syncContainersWithDatabase() // Sync up before fetching to ensure we have the latest status\n\n    const query = Service.query()\n      .orderBy('display_order', 'asc')\n      .orderBy('friendly_name', 'asc')\n      .select(\n        'id',\n        'service_name',\n        'installed',\n        'installation_status',\n        'ui_location',\n        'friendly_name',\n        'description',\n        'icon',\n        'powered_by',\n        'display_order',\n        'container_image',\n        'available_update_version'\n      )\n      .where('is_dependency_service', false)\n    if (installedOnly) {\n      query.where('installed', true)\n    }\n\n    const services = await query\n    if (!services || services.length === 0) {\n      return []\n    }\n\n    const statuses = await this.dockerService.getServicesStatus()\n\n    const toReturn: ServiceSlim[] = []\n\n    for (const service of services) {\n      const status = statuses.find((s) => s.service_name === service.service_name)\n      toReturn.push({\n        id: service.id,\n        service_name: service.service_name,\n        friendly_name: service.friendly_name,\n        description: service.description,\n        icon: service.icon,\n        installed: service.installed,\n        installation_status: service.installation_status,\n        status: status ? status.status : 'unknown',\n        ui_location: service.ui_location || '',\n        powered_by: service.powered_by,\n        display_order: service.display_order,\n        container_image: service.container_image,\n        available_update_version: service.available_update_version,\n      })\n    }\n\n    return toReturn\n  }\n\n  static getAppVersion(): string {\n    try {\n      if (this.appVersion) {\n        return this.appVersion\n      }\n\n      // Return 'dev' for development environment (version.json won't exist)\n      if (process.env.NODE_ENV === 'development') {\n        this.appVersion = 'dev'\n        return 'dev'\n      }\n\n      const packageJson = readFileSync(join(process.cwd(), 'version.json'), 'utf-8')\n      const packageData = JSON.parse(packageJson)\n\n      const version = packageData.version || '0.0.0'\n\n      this.appVersion = version\n      return version\n    } catch (error) {\n      logger.error('Error getting app version:', error)\n      return '0.0.0'\n    }\n  }\n\n  async getSystemInfo(): Promise<SystemInformationResponse | undefined> {\n    try {\n      const [cpu, mem, os, currentLoad, fsSize, uptime, graphics] = await Promise.all([\n        si.cpu(),\n        si.mem(),\n        si.osInfo(),\n        si.currentLoad(),\n        si.fsSize(),\n        si.time(),\n        si.graphics(),\n      ])\n\n      let diskInfo: NomadDiskInfoRaw | undefined\n      let disk: NomadDiskInfo[] = []\n\n      try {\n        const diskInfoRawString = await getFile(\n          path.join(process.cwd(), SystemService.diskInfoFile),\n          'string'\n        )\n\n        diskInfo = (\n          diskInfoRawString\n            ? JSON.parse(diskInfoRawString.toString())\n            : { diskLayout: { blockdevices: [] }, fsSize: [] }\n        ) as NomadDiskInfoRaw\n\n        disk = this.calculateDiskUsage(diskInfo)\n      } catch (error) {\n        logger.error('Error reading disk info file:', error)\n      }\n\n      // GPU health tracking — detect when host has NVIDIA GPU but Ollama can't access it\n      let gpuHealth: GpuHealthStatus = {\n        status: 'no_gpu',\n        hasNvidiaRuntime: false,\n        ollamaGpuAccessible: false,\n      }\n\n      // Query Docker API for host-level info (hostname, OS, GPU runtime)\n      // si.osInfo() returns the container's info inside Docker, not the host's\n      try {\n        const dockerInfo = await this.dockerService.docker.info()\n\n        if (dockerInfo.Name) {\n          os.hostname = dockerInfo.Name\n        }\n        if (dockerInfo.OperatingSystem) {\n          os.distro = dockerInfo.OperatingSystem\n        }\n        if (dockerInfo.KernelVersion) {\n          os.kernel = dockerInfo.KernelVersion\n        }\n\n        // If si.graphics() returned no controllers (common inside Docker),\n        // fall back to nvidia runtime + nvidia-smi detection\n        if (!graphics.controllers || graphics.controllers.length === 0) {\n          const runtimes = dockerInfo.Runtimes || {}\n          if ('nvidia' in runtimes) {\n            gpuHealth.hasNvidiaRuntime = true\n            const nvidiaInfo = await this.getNvidiaSmiInfo()\n            if (Array.isArray(nvidiaInfo)) {\n              graphics.controllers = nvidiaInfo.map((gpu) => ({\n                model: gpu.model,\n                vendor: gpu.vendor,\n                bus: \"\",\n                vram: gpu.vram,\n                vramDynamic: false, // assume false here, we don't actually use this field for our purposes.\n              }))\n              gpuHealth.status = 'ok'\n              gpuHealth.ollamaGpuAccessible = true\n            } else if (nvidiaInfo === 'OLLAMA_NOT_FOUND') {\n              gpuHealth.status = 'ollama_not_installed'\n            } else {\n              gpuHealth.status = 'passthrough_failed'\n              logger.warn(`NVIDIA runtime detected but GPU passthrough failed: ${typeof nvidiaInfo === 'string' ? nvidiaInfo : JSON.stringify(nvidiaInfo)}`)\n            }\n          }\n        } else {\n          // si.graphics() returned controllers (host install, not Docker) — GPU is working\n          gpuHealth.status = 'ok'\n          gpuHealth.ollamaGpuAccessible = true\n        }\n      } catch {\n        // Docker info query failed, skip host-level enrichment\n      }\n\n      return {\n        cpu,\n        mem,\n        os,\n        disk,\n        currentLoad,\n        fsSize,\n        uptime,\n        graphics,\n        gpuHealth,\n      }\n    } catch (error) {\n      logger.error('Error getting system info:', error)\n      return undefined\n    }\n  }\n\n  async checkLatestVersion(force?: boolean): Promise<{\n    success: boolean\n    updateAvailable: boolean\n    currentVersion: string\n    latestVersion: string\n    message?: string\n  }> {\n    try {\n      const currentVersion = SystemService.getAppVersion()\n      const cachedUpdateAvailable = await KVStore.getValue('system.updateAvailable')\n      const cachedLatestVersion = await KVStore.getValue('system.latestVersion')\n\n      // Use cached values if not forcing a fresh check.\n      // the CheckUpdateJob will update these values every 12 hours\n      if (!force) {\n        return {\n          success: true,\n          updateAvailable: cachedUpdateAvailable ?? false,\n          currentVersion,\n          latestVersion: cachedLatestVersion || '',\n        }\n      }\n\n      const earlyAccess = (await KVStore.getValue('system.earlyAccess')) ?? false\n\n      let latestVersion: string\n      if (earlyAccess) {\n        const response = await axios.get(\n          'https://api.github.com/repos/Crosstalk-Solutions/project-nomad/releases',\n          { headers: { Accept: 'application/vnd.github+json' }, timeout: 5000 }\n        )\n        if (!response?.data?.length) throw new Error('No releases found')\n        latestVersion = response.data[0].tag_name.replace(/^v/, '').trim()\n      } else {\n        const response = await axios.get(\n          'https://api.github.com/repos/Crosstalk-Solutions/project-nomad/releases/latest',\n          { headers: { Accept: 'application/vnd.github+json' }, timeout: 5000 }\n        )\n        if (!response?.data?.tag_name) throw new Error('Invalid response from GitHub API')\n        latestVersion = response.data.tag_name.replace(/^v/, '').trim()\n      }\n\n      logger.info(`Current version: ${currentVersion}, Latest version: ${latestVersion}`)\n\n      const updateAvailable = process.env.NODE_ENV === 'development'\n        ? false\n        : isNewerVersion(latestVersion, currentVersion.trim(), earlyAccess)\n\n      // Cache the results in KVStore for frontend checks\n      await KVStore.setValue('system.updateAvailable', updateAvailable)\n      await KVStore.setValue('system.latestVersion', latestVersion)\n\n      return {\n        success: true,\n        updateAvailable,\n        currentVersion,\n        latestVersion,\n      }\n    } catch (error) {\n      logger.error('Error checking latest version:', error)\n      return {\n        success: false,\n        updateAvailable: false,\n        currentVersion: '',\n        latestVersion: '',\n        message: `Failed to check latest version: ${error instanceof Error ? error.message : error}`,\n      }\n    }\n  }\n\n  async subscribeToReleaseNotes(email: string): Promise<{ success: boolean; message: string }> {\n    try {\n      const response = await axios.post(\n        'https://api.projectnomad.us/api/v1/lists/release-notes/subscribe',\n        { email },\n        { timeout: 5000 }\n      )\n\n      if (response.status === 200) {\n        return {\n          success: true,\n          message: 'Successfully subscribed to release notes',\n        }\n      }\n\n      return {\n        success: false,\n        message: `Failed to subscribe: ${response.statusText}`,\n      }\n    } catch (error) {\n      logger.error('Error subscribing to release notes:', error)\n      return {\n        success: false,\n        message: `Failed to subscribe: ${error instanceof Error ? error.message : error}`,\n      }\n    }\n  }\n\n  async getDebugInfo(): Promise<string> {\n    const appVersion = SystemService.getAppVersion()\n    const environment = process.env.NODE_ENV || 'unknown'\n\n    const [systemInfo, services, internetStatus, versionCheck] = await Promise.all([\n      this.getSystemInfo(),\n      this.getServices({ installedOnly: false }),\n      this.getInternetStatus().catch(() => null),\n      this.checkLatestVersion().catch(() => null),\n    ])\n\n    const lines: string[] = [\n      'Project NOMAD Debug Info',\n      '========================',\n      `App Version: ${appVersion}`,\n      `Environment: ${environment}`,\n    ]\n\n    if (systemInfo) {\n      const { cpu, mem, os, disk, fsSize, uptime, graphics } = systemInfo\n\n      lines.push('')\n      lines.push('System:')\n      if (os.distro) lines.push(`  OS: ${os.distro}`)\n      if (os.hostname) lines.push(`  Hostname: ${os.hostname}`)\n      if (os.kernel) lines.push(`  Kernel: ${os.kernel}`)\n      if (os.arch) lines.push(`  Architecture: ${os.arch}`)\n      if (uptime?.uptime) lines.push(`  Uptime: ${this._formatUptime(uptime.uptime)}`)\n\n      lines.push('')\n      lines.push('Hardware:')\n      if (cpu.brand) {\n        lines.push(`  CPU: ${cpu.brand} (${cpu.cores} cores)`)\n      }\n      if (mem.total) {\n        const total = this._formatBytes(mem.total)\n        const used = this._formatBytes(mem.total - (mem.available || 0))\n        const available = this._formatBytes(mem.available || 0)\n        lines.push(`  RAM: ${total} total, ${used} used, ${available} available`)\n      }\n      if (graphics.controllers && graphics.controllers.length > 0) {\n        for (const gpu of graphics.controllers) {\n          const vram = gpu.vram ? ` (${gpu.vram} MB VRAM)` : ''\n          lines.push(`  GPU: ${gpu.model}${vram}`)\n        }\n      } else {\n        lines.push('  GPU: None detected')\n      }\n\n      // Disk info — try disk array first, fall back to fsSize\n      const diskEntries = disk.filter((d) => d.totalSize > 0)\n      if (diskEntries.length > 0) {\n        for (const d of diskEntries) {\n          const size = this._formatBytes(d.totalSize)\n          const type = d.tran?.toUpperCase() || (d.rota ? 'HDD' : 'SSD')\n          lines.push(`  Disk: ${size}, ${Math.round(d.percentUsed)}% used, ${type}`)\n        }\n      } else if (fsSize.length > 0) {\n        const realFs = fsSize.filter((f) => f.fs.startsWith('/dev/'))\n        const seen = new Set<number>()\n        for (const f of realFs) {\n          if (seen.has(f.size)) continue\n          seen.add(f.size)\n          lines.push(`  Disk: ${this._formatBytes(f.size)}, ${Math.round(f.use)}% used`)\n        }\n      }\n    }\n\n    const installed = services.filter((s) => s.installed)\n    lines.push('')\n    if (installed.length > 0) {\n      lines.push('Installed Services:')\n      for (const svc of installed) {\n        lines.push(`  ${svc.friendly_name} (${svc.service_name}): ${svc.status}`)\n      }\n    } else {\n      lines.push('Installed Services: None')\n    }\n\n    if (internetStatus !== null) {\n      lines.push('')\n      lines.push(`Internet Status: ${internetStatus ? 'Online' : 'Offline'}`)\n    }\n\n    if (versionCheck?.success) {\n      const updateMsg = versionCheck.updateAvailable\n        ? `Yes (${versionCheck.latestVersion} available)`\n        : `No (${versionCheck.currentVersion} is latest)`\n      lines.push(`Update Available: ${updateMsg}`)\n    }\n\n    return lines.join('\\n')\n  }\n\n  private _formatUptime(seconds: number): string {\n    const days = Math.floor(seconds / 86400)\n    const hours = Math.floor((seconds % 86400) / 3600)\n    const minutes = Math.floor((seconds % 3600) / 60)\n    if (days > 0) return `${days}d ${hours}h ${minutes}m`\n    if (hours > 0) return `${hours}h ${minutes}m`\n    return `${minutes}m`\n  }\n\n  private _formatBytes(bytes: number, decimals = 1): string {\n    if (bytes === 0) return '0 Bytes'\n    const k = 1024\n    const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']\n    const i = Math.floor(Math.log(bytes) / Math.log(k))\n    return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i]\n  }\n\n  async updateSetting(key: KVStoreKey, value: any): Promise<void> {\n    if ((value === '' || value === undefined || value === null) && KV_STORE_SCHEMA[key] === 'string') {\n      await KVStore.clearValue(key)\n    } else {\n      await KVStore.setValue(key, value)\n    }\n  }\n\n  /**\n   * Checks the current state of Docker containers against the database records and updates the database accordingly.\n   * It will mark services as not installed if their corresponding containers do not exist, regardless of their running state.\n   * Handles cases where a container might have been manually removed, ensuring the database reflects the actual existence of containers.\n   * Containers that exist but are stopped, paused, or restarting will still be considered installed.\n   */\n  private async _syncContainersWithDatabase() {\n    try {\n      const allServices = await Service.all()\n      const serviceStatusList = await this.dockerService.getServicesStatus()\n\n      for (const service of allServices) {\n        const containerExists = serviceStatusList.find(\n          (s) => s.service_name === service.service_name\n        )\n\n        if (service.installed) {\n          // If marked as installed but container doesn't exist, mark as not installed\n          if (!containerExists) {\n            logger.warn(\n              `Service ${service.service_name} is marked as installed but container does not exist. Marking as not installed.`\n            )\n            service.installed = false\n            service.installation_status = 'idle'\n            await service.save()\n          }\n        } else {\n          // If marked as not installed but container exists (any state), mark as installed\n          if (containerExists) {\n            logger.warn(\n              `Service ${service.service_name} is marked as not installed but container exists. Marking as installed.`\n            )\n            service.installed = true\n            service.installation_status = 'idle'\n            await service.save()\n          }\n        }\n      }\n    } catch (error) {\n      logger.error('Error syncing containers with database:', error)\n    }\n  }\n\n  private calculateDiskUsage(diskInfo: NomadDiskInfoRaw): NomadDiskInfo[] {\n    const { diskLayout, fsSize } = diskInfo\n\n    if (!diskLayout?.blockdevices || !fsSize) {\n      return []\n    }\n\n    // Deduplicate: same device path mounted in multiple places (Docker bind-mounts)\n    // Keep the entry with the largest size — that's the real partition\n    const deduped = new Map<string, NomadDiskInfoRaw['fsSize'][0]>()\n    for (const entry of fsSize) {\n      const existing = deduped.get(entry.fs)\n      if (!existing || entry.size > existing.size) {\n        deduped.set(entry.fs, entry)\n      }\n    }\n    const dedupedFsSize = Array.from(deduped.values())\n\n    return diskLayout.blockdevices\n      .filter((disk) => disk.type === 'disk') // Only physical disks\n      .map((disk) => {\n        const filesystems = getAllFilesystems(disk, dedupedFsSize)\n\n        // Across all partitions\n        const totalUsed = filesystems.reduce((sum, p) => sum + (p.used || 0), 0)\n        const totalSize = filesystems.reduce((sum, p) => sum + (p.size || 0), 0)\n        const percentUsed = totalSize > 0 ? (totalUsed / totalSize) * 100 : 0\n\n        return {\n          name: disk.name,\n          model: disk.model || 'Unknown',\n          vendor: disk.vendor || '',\n          rota: disk.rota || false,\n          tran: disk.tran || '',\n          size: disk.size,\n          totalUsed,\n          totalSize,\n          percentUsed: Math.round(percentUsed * 100) / 100,\n          filesystems: filesystems.map((p) => ({\n            fs: p.fs,\n            mount: p.mount,\n            used: p.used,\n            size: p.size,\n            percentUsed: p.use,\n          })),\n        }\n      })\n  }\n\n}\n"
  },
  {
    "path": "admin/app/services/system_update_service.ts",
    "content": "import logger from '@adonisjs/core/services/logger'\nimport { readFileSync, existsSync } from 'fs'\nimport { writeFile } from 'fs/promises'\nimport { join } from 'path'\nimport KVStore from '#models/kv_store'\n\ninterface UpdateStatus {\n  stage: 'idle' | 'starting' | 'pulling' | 'pulled' | 'recreating' | 'complete' | 'error'\n  progress: number\n  message: string\n  timestamp: string\n}\n\nexport class SystemUpdateService {\n  private static SHARED_DIR = '/app/update-shared'\n  private static REQUEST_FILE = join(SystemUpdateService.SHARED_DIR, 'update-request')\n  private static STATUS_FILE = join(SystemUpdateService.SHARED_DIR, 'update-status')\n  private static LOG_FILE = join(SystemUpdateService.SHARED_DIR, 'update-log')\n\n  /**\n   * Requests a system update by creating a request file that the sidecar will detect\n   */\n  async requestUpdate(): Promise<{ success: boolean; message: string }> {\n    try {\n      const currentStatus = this.getUpdateStatus()\n      if (currentStatus && !['idle', 'complete', 'error'].includes(currentStatus.stage)) {\n        return {\n          success: false,\n          message: `Update already in progress (stage: ${currentStatus.stage})`,\n        }\n      }\n\n      // Determine the Docker image tag to install.\n      const latestVersion = await KVStore.getValue('system.latestVersion')\n\n      const requestData = {\n        requested_at: new Date().toISOString(),\n        requester: 'admin-api',\n        target_tag: latestVersion ? `v${latestVersion}` : 'latest',\n      }\n\n      await writeFile(SystemUpdateService.REQUEST_FILE, JSON.stringify(requestData, null, 2))\n      logger.info(`[SystemUpdateService]: System update requested (target tag: ${requestData.target_tag}) - sidecar will process shortly`)\n\n      return {\n        success: true,\n        message: 'System update initiated. The admin container will restart during the process.',\n      }\n    } catch (error) {\n      logger.error('[SystemUpdateService]: Failed to request system update:', error)\n      return {\n        success: false,\n        message: `Failed to request update: ${error.message}`,\n      }\n    }\n  }\n\n  getUpdateStatus(): UpdateStatus | null {\n    try {\n      if (!existsSync(SystemUpdateService.STATUS_FILE)) {\n        return {\n          stage: 'idle',\n          progress: 0,\n          message: 'No update in progress',\n          timestamp: new Date().toISOString(),\n        }\n      }\n\n      const statusContent = readFileSync(SystemUpdateService.STATUS_FILE, 'utf-8')\n      return JSON.parse(statusContent) as UpdateStatus\n    } catch (error) {\n      logger.error('[SystemUpdateService]: Failed to read update status:', error)\n      return null\n    }\n  }\n\n  getUpdateLogs(): string {\n    try {\n      if (!existsSync(SystemUpdateService.LOG_FILE)) {\n        return 'No update logs available'\n      }\n\n      return readFileSync(SystemUpdateService.LOG_FILE, 'utf-8')\n    } catch (error) {\n      logger.error('[SystemUpdateService]: Failed to read update logs:', error)\n      return `Error reading logs: ${error.message}`\n    }\n  }\n\n  /**\n   * Check if the update sidecar is reachable (i.e. shared volume is mounted)\n   */\n  isSidecarAvailable(): boolean {\n    try {\n      return existsSync(SystemUpdateService.SHARED_DIR)\n    } catch (error) {\n      return false\n    }\n  }\n}\n"
  },
  {
    "path": "admin/app/services/zim_extraction_service.ts",
    "content": "import { Archive, Entry } from '@openzim/libzim'\nimport * as cheerio from 'cheerio'\nimport { HTML_SELECTORS_TO_REMOVE, NON_CONTENT_HEADING_PATTERNS } from '../../constants/zim_extraction.js'\nimport logger from '@adonisjs/core/services/logger'\nimport { ExtractZIMChunkingStrategy, ExtractZIMContentOptions, ZIMContentChunk, ZIMArchiveMetadata } from '../../types/zim.js'\nimport { randomUUID } from 'node:crypto'\nimport { access } from 'node:fs/promises'\n\nexport class ZIMExtractionService {\n\n    private extractArchiveMetadata(archive: Archive): ZIMArchiveMetadata {\n        try {\n            return {\n                title: archive.getMetadata('Title') || archive.getMetadata('Name') || 'Unknown',\n                creator: archive.getMetadata('Creator') || 'Unknown',\n                publisher: archive.getMetadata('Publisher') || 'Unknown',\n                date: archive.getMetadata('Date') || 'Unknown',\n                language: archive.getMetadata('Language') || 'Unknown',\n                description: archive.getMetadata('Description') || '',\n            }\n        } catch (error) {\n            logger.warn('[ZIMExtractionService]: Could not extract all metadata, using defaults', error)\n            return {\n                title: 'Unknown',\n                creator: 'Unknown',\n                publisher: 'Unknown',\n                date: 'Unknown',\n                language: 'Unknown',\n                description: '',\n            }\n        }\n    }\n\n    /**\n     * Breaks out a ZIM file's entries into their structured content form\n     * to facilitate better indexing and retrieval.\n     * Returns enhanced chunks with full article context and metadata.\n     * \n     * @param filePath - Path to the ZIM file\n     * @param opts - Options including maxArticles, strategy, onProgress, startOffset, and batchSize\n     */\n    async extractZIMContent(filePath: string, opts: ExtractZIMContentOptions = {}): Promise<ZIMContentChunk[]> {\n        try {\n            logger.info(`[ZIMExtractionService]: Processing ZIM file at path: ${filePath}`)\n            \n            // defensive - check if file still exists before opening\n            // could have been deleted by another process or batch\n            try {\n                await access(filePath)\n            } catch (error) {\n                logger.error(`[ZIMExtractionService]: ZIM file not accessible: ${filePath}`)\n                throw new Error(`ZIM file not found or not accessible: ${filePath}`)\n            }\n            \n            const archive = new Archive(filePath)\n\n            // Extract archive-level metadata once\n            const archiveMetadata = this.extractArchiveMetadata(archive)\n            logger.info(`[ZIMExtractionService]: Archive metadata - Title: ${archiveMetadata.title}, Language: ${archiveMetadata.language}`)\n\n            let articlesProcessed = 0\n            let articlesSkipped = 0\n            const processedPaths = new Set<string>()\n            const toReturn: ZIMContentChunk[] = []\n\n            // Support batch processing to avoid lock timeouts on large ZIM files\n            const startOffset = opts.startOffset || 0\n            const batchSize = opts.batchSize || (opts.maxArticles || Infinity)\n\n            for (const entry of archive.iterByPath()) {\n                // Skip articles until we reach the start offset\n                if (articlesSkipped < startOffset) {\n                    if (this.isArticleEntry(entry) && !processedPaths.has(entry.path)) {\n                        articlesSkipped++\n                    }\n                    continue\n                }\n\n                if (articlesProcessed >= batchSize) {\n                    break\n                }\n\n                if (!this.isArticleEntry(entry)) {\n                    logger.debug(`[ZIMExtractionService]: Skipping non-article entry at path: ${entry.path}`)\n                    continue\n                }\n\n                if (processedPaths.has(entry.path)) {\n                    logger.debug(`[ZIMExtractionService]: Skipping duplicate entry at path: ${entry.path}`)\n                    continue\n                }\n                processedPaths.add(entry.path)\n\n                const item = entry.item\n                const blob = item.data\n                const html = this.getCleanedHTMLString(blob.data)\n\n                const strategy = opts.strategy || this.chooseChunkingStrategy(html);\n                logger.debug(`[ZIMExtractionService]: Chosen chunking strategy for path ${entry.path}: ${strategy}`)\n\n                // Generate a unique document ID. All chunks from same article will share it\n                const documentId = randomUUID()\n                const articleTitle = entry.title || entry.path\n\n                let chunks: ZIMContentChunk[]\n\n                if (strategy === 'structured') {\n                    const structured = this.extractStructuredContent(html)\n                    chunks = structured.sections.map(s => ({\n                        text: s.text,\n                        articleTitle,\n                        articlePath: entry.path,\n                        sectionTitle: s.heading,\n                        fullTitle: `${articleTitle} - ${s.heading}`,\n                        hierarchy: `${articleTitle} > ${s.heading}`,\n                        sectionLevel: s.level,\n                        documentId,\n                        archiveMetadata,\n                        strategy,\n                    }))\n                } else {\n                    // Simple strategy - entire article as one chunk\n                    const text = this.extractTextFromHTML(html) || ''\n                    chunks = [{\n                        text,\n                        articleTitle,\n                        articlePath: entry.path,\n                        sectionTitle: articleTitle, // Same as article for simple strategy\n                        fullTitle: articleTitle,\n                        hierarchy: articleTitle,\n                        documentId,\n                        archiveMetadata,\n                        strategy,\n                    }]\n                }\n\n                logger.debug(`Extracted ${chunks.length} chunks from article at path: ${entry.path} using strategy: ${strategy}`)\n\n                const nonEmptyChunks = chunks.filter(c => c.text.trim().length > 0)\n                logger.debug(`After filtering empty chunks, ${nonEmptyChunks.length} chunks remain for article at path: ${entry.path}`)\n                toReturn.push(...nonEmptyChunks)\n                articlesProcessed++\n\n                if (opts.onProgress) {\n                    opts.onProgress(articlesProcessed, archive.articleCount)\n                }\n            }\n\n            logger.info(`[ZIMExtractionService]: Completed processing ZIM file. Total articles processed: ${articlesProcessed}`)\n            logger.debug(\"Final structured content sample:\", toReturn.slice(0, 3).map(c => ({\n                articleTitle: c.articleTitle,\n                sectionTitle: c.sectionTitle,\n                hierarchy: c.hierarchy,\n                textPreview: c.text.substring(0, 100)\n            })))\n            logger.debug(\"Total structured sections extracted:\", toReturn.length)\n            return toReturn\n        } catch (error) {\n            logger.error('Error processing ZIM file:', error)\n            throw error\n        }\n    }\n\n    private chooseChunkingStrategy(html: string, options = {\n        forceStrategy: null as ExtractZIMChunkingStrategy | null,\n    }): ExtractZIMChunkingStrategy {\n        const {\n            forceStrategy = null,\n        } = options;\n\n        if (forceStrategy) return forceStrategy;\n\n        // Use a simple analysis to determin if the HTML has any meaningful structure\n        // that we can leverage for better chunking. If not, we'll just chunk it as one big piece of text.\n        return this.hasStructuredHeadings(html) ? 'structured' : 'simple';\n    }\n\n    private getCleanedHTMLString(buff: Buffer<ArrayBufferLike>): string {\n        const rawString = buff.toString('utf-8');\n        const $ = cheerio.load(rawString);\n\n        HTML_SELECTORS_TO_REMOVE.forEach((selector) => {\n            $(selector).remove()\n        });\n\n        return $.html();\n    }\n\n    private extractTextFromHTML(html: string): string | null {\n        try {\n            const $ = cheerio.load(html)\n\n            // Search body first, then root if body is absent\n            const text = $('body').length ? $('body').text() : $.root().text()\n\n            return text.replace(/\\s+/g, ' ').replace(/\\n\\s*\\n/g, '\\n').trim()\n        } catch (error) {\n            logger.error('Error extracting text from HTML:', error)\n            return null\n        }\n    }\n\n    private extractStructuredContent(html: string) {\n        const $ = cheerio.load(html);\n\n        const title = $('h1').first().text().trim() || $('title').text().trim();\n\n        // Extract sections with their headings and heading levels\n        const sections: Array<{ heading: string; text: string; level: number }> = [];\n        let currentSection = { heading: 'Introduction', content: [] as string[], level: 2 };\n\n        $('body').children().each((_, element) => {\n            const $el = $(element);\n            const tagName = element.tagName?.toLowerCase();\n\n            if (['h2', 'h3', 'h4'].includes(tagName)) {\n                // Save current section if it has content\n                if (currentSection.content.length > 0) {\n                    sections.push({\n                        heading: currentSection.heading,\n                        text: currentSection.content.join(' ').replace(/\\s+/g, ' ').trim(),\n                        level: currentSection.level,\n                    });\n                }\n                // Start new section\n                const level = parseInt(tagName.substring(1)); // Extract number from h2, h3, h4\n                currentSection = {\n                    heading: $el.text().replace(/\\[edit\\]/gi, '').trim(),\n                    content: [],\n                    level,\n                };\n            } else if (['p', 'ul', 'ol', 'dl', 'table'].includes(tagName)) {\n                const text = $el.text().trim();\n                if (text.length > 0) {\n                    currentSection.content.push(text);\n                }\n            }\n        });\n\n        // Push the last section if it has content\n        if (currentSection.content.length > 0) {\n            sections.push({\n                heading: currentSection.heading,\n                text: currentSection.content.join(' ').replace(/\\s+/g, ' ').trim(),\n                level: currentSection.level,\n            });\n        }\n\n        return {\n            title,\n            sections,\n            fullText: sections.map(s => `${s.heading}\\n${s.text}`).join('\\n\\n'),\n        };\n    }\n\n    private hasStructuredHeadings(html: string): boolean {\n        const $ = cheerio.load(html);\n\n        const headings = $('h2, h3').toArray();\n\n        // Consider it structured if it has at least 2 headings to break content into meaningful sections\n        if (headings.length < 2) return false;\n\n        // Check that headings have substantial content between them\n        let sectionsWithContent = 0;\n\n        for (const heading of headings) {\n            const $heading = $(heading);\n            const headingText = $heading.text().trim();\n\n            // Skip empty or very short headings, likely not meaningful\n            if (headingText.length < 3) continue;\n\n            // Skip common non-content headings\n            if (NON_CONTENT_HEADING_PATTERNS.some(pattern => pattern.test(headingText))) {\n                continue;\n            }\n\n            // Content until next heading\n            let contentLength = 0;\n            let $next = $heading.next();\n\n            while ($next.length && !$next.is('h1, h2, h3, h4')) {\n                contentLength += $next.text().trim().length;\n                $next = $next.next();\n            }\n\n            // Consider it a real section if it has at least 100 chars of content\n            if (contentLength >= 100) {\n                sectionsWithContent++;\n            }\n        }\n\n        // Require at least 2 sections with substantial content\n        return sectionsWithContent >= 2;\n    }\n\n    private isArticleEntry(entry: Entry): boolean {\n        try {\n            if (entry.isRedirect) return false;\n\n            const item = entry.item;\n            const mimeType = item.mimetype;\n\n            return mimeType === 'text/html' || mimeType === 'application/xhtml+xml';\n        } catch {\n            return false;\n        }\n    }\n}"
  },
  {
    "path": "admin/app/services/zim_service.ts",
    "content": "import {\n  ListRemoteZimFilesResponse,\n  RawRemoteZimFileEntry,\n  RemoteZimFileEntry,\n} from '../../types/zim.js'\nimport axios from 'axios'\nimport { XMLParser } from 'fast-xml-parser'\nimport { isRawListRemoteZimFilesResponse, isRawRemoteZimFileEntry } from '../../util/zim.js'\nimport logger from '@adonisjs/core/services/logger'\nimport { DockerService } from './docker_service.js'\nimport { inject } from '@adonisjs/core'\nimport {\n  deleteFileIfExists,\n  ensureDirectoryExists,\n  getFileStatsIfExists,\n  listDirectoryContents,\n  ZIM_STORAGE_PATH,\n} from '../utils/fs.js'\nimport { join, resolve, sep } from 'path'\nimport { WikipediaOption, WikipediaState } from '../../types/downloads.js'\nimport vine from '@vinejs/vine'\nimport { wikipediaOptionsFileSchema } from '#validators/curated_collections'\nimport WikipediaSelection from '#models/wikipedia_selection'\nimport InstalledResource from '#models/installed_resource'\nimport { RunDownloadJob } from '#jobs/run_download_job'\nimport { SERVICE_NAMES } from '../../constants/service_names.js'\nimport { CollectionManifestService } from './collection_manifest_service.js'\nimport type { CategoryWithStatus } from '../../types/collections.js'\n\nconst ZIM_MIME_TYPES = ['application/x-zim', 'application/x-openzim', 'application/octet-stream']\nconst WIKIPEDIA_OPTIONS_URL = 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/wikipedia.json'\n\n@inject()\nexport class ZimService {\n  constructor(private dockerService: DockerService) { }\n\n  async list() {\n    const dirPath = join(process.cwd(), ZIM_STORAGE_PATH)\n    await ensureDirectoryExists(dirPath)\n\n    const all = await listDirectoryContents(dirPath)\n    const files = all.filter((item) => item.name.endsWith('.zim'))\n\n    return {\n      files,\n    }\n  }\n\n  async listRemote({\n    start,\n    count,\n    query,\n  }: {\n    start: number\n    count: number\n    query?: string\n  }): Promise<ListRemoteZimFilesResponse> {\n    const LIBRARY_BASE_URL = 'https://browse.library.kiwix.org/catalog/v2/entries'\n\n    const res = await axios.get(LIBRARY_BASE_URL, {\n      params: {\n        start: start,\n        count: count,\n        lang: 'eng',\n        ...(query ? { q: query } : {}),\n      },\n      responseType: 'text',\n    })\n\n    const data = res.data\n    const parser = new XMLParser({\n      ignoreAttributes: false,\n      attributeNamePrefix: '',\n      textNodeName: '#text',\n    })\n    const result = parser.parse(data)\n\n    if (!isRawListRemoteZimFilesResponse(result)) {\n      throw new Error('Invalid response format from remote library')\n    }\n\n    const entries = result.feed.entry\n      ? Array.isArray(result.feed.entry)\n        ? result.feed.entry\n        : [result.feed.entry]\n      : []\n\n    const filtered = entries.filter((entry: any) => {\n      return isRawRemoteZimFileEntry(entry)\n    })\n\n    const mapped: (RemoteZimFileEntry | null)[] = filtered.map((entry: RawRemoteZimFileEntry) => {\n      const downloadLink = entry.link.find((link: any) => {\n        return (\n          typeof link === 'object' &&\n          'rel' in link &&\n          'length' in link &&\n          'href' in link &&\n          'type' in link &&\n          link.type === 'application/x-zim'\n        )\n      })\n\n      if (!downloadLink) {\n        return null\n      }\n\n      // downloadLink['href'] will end with .meta4, we need to remove that to get the actual download URL\n      const download_url = downloadLink['href'].substring(0, downloadLink['href'].length - 6)\n      const file_name = download_url.split('/').pop() || `${entry.title}.zim`\n      const sizeBytes = parseInt(downloadLink['length'], 10)\n\n      return {\n        id: entry.id,\n        title: entry.title,\n        updated: entry.updated,\n        summary: entry.summary,\n        size_bytes: sizeBytes || 0,\n        download_url: download_url,\n        author: entry.author.name,\n        file_name: file_name,\n      }\n    })\n\n    // Filter out any null entries (those without a valid download link)\n    // or files that already exist in the local storage\n    const existing = await this.list()\n    const existingKeys = new Set(existing.files.map((file) => file.name))\n    const withoutExisting = mapped.filter(\n      (entry): entry is RemoteZimFileEntry => entry !== null && !existingKeys.has(entry.file_name)\n    )\n\n    return {\n      items: withoutExisting,\n      has_more: result.feed.totalResults > start,\n      total_count: result.feed.totalResults,\n    }\n  }\n\n  async downloadRemote(url: string): Promise<{ filename: string; jobId?: string }> {\n    const parsed = new URL(url)\n    if (!parsed.pathname.endsWith('.zim')) {\n      throw new Error(`Invalid ZIM file URL: ${url}. URL must end with .zim`)\n    }\n\n    const existing = await RunDownloadJob.getByUrl(url)\n    if (existing) {\n      throw new Error('A download for this URL is already in progress')\n    }\n\n    // Extract the filename from the URL\n    const filename = url.split('/').pop()\n    if (!filename) {\n      throw new Error('Could not determine filename from URL')\n    }\n\n    const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)\n\n    // Parse resource metadata for the download job\n    const parsedFilename = CollectionManifestService.parseZimFilename(filename)\n    const resourceMetadata = parsedFilename\n      ? { resource_id: parsedFilename.resource_id, version: parsedFilename.version, collection_ref: null }\n      : undefined\n\n    // Dispatch a background download job\n    const result = await RunDownloadJob.dispatch({\n      url,\n      filepath,\n      timeout: 30000,\n      allowedMimeTypes: ZIM_MIME_TYPES,\n      forceNew: true,\n      filetype: 'zim',\n      resourceMetadata,\n    })\n\n    if (!result || !result.job) {\n      throw new Error('Failed to dispatch download job')\n    }\n\n    logger.info(`[ZimService] Dispatched background download job for ZIM file: ${filename}`)\n\n    return {\n      filename,\n      jobId: result.job.id,\n    }\n  }\n\n  async listCuratedCategories(): Promise<CategoryWithStatus[]> {\n    const manifestService = new CollectionManifestService()\n    return manifestService.getCategoriesWithStatus()\n  }\n\n  async downloadCategoryTier(categorySlug: string, tierSlug: string): Promise<string[] | null> {\n    const manifestService = new CollectionManifestService()\n    const spec = await manifestService.getSpecWithFallback<import('../../types/collections.js').ZimCategoriesSpec>('zim_categories')\n    if (!spec) {\n      throw new Error('Could not load ZIM categories spec')\n    }\n\n    const category = spec.categories.find((c) => c.slug === categorySlug)\n    if (!category) {\n      throw new Error(`Category not found: ${categorySlug}`)\n    }\n\n    const tier = category.tiers.find((t) => t.slug === tierSlug)\n    if (!tier) {\n      throw new Error(`Tier not found: ${tierSlug}`)\n    }\n\n    const allResources = CollectionManifestService.resolveTierResources(tier, category.tiers)\n\n    // Filter out already installed\n    const installed = await InstalledResource.query().where('resource_type', 'zim')\n    const installedIds = new Set(installed.map((r) => r.resource_id))\n    const toDownload = allResources.filter((r) => !installedIds.has(r.id))\n\n    if (toDownload.length === 0) return null\n\n    const downloadFilenames: string[] = []\n\n    for (const resource of toDownload) {\n      const existingJob = await RunDownloadJob.getByUrl(resource.url)\n      if (existingJob) {\n        logger.warn(`[ZimService] Download already in progress for ${resource.url}, skipping.`)\n        continue\n      }\n\n      const filename = resource.url.split('/').pop()\n      if (!filename) continue\n\n      downloadFilenames.push(filename)\n      const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)\n\n      await RunDownloadJob.dispatch({\n        url: resource.url,\n        filepath,\n        timeout: 30000,\n        allowedMimeTypes: ZIM_MIME_TYPES,\n        forceNew: true,\n        filetype: 'zim',\n        resourceMetadata: {\n          resource_id: resource.id,\n          version: resource.version,\n          collection_ref: categorySlug,\n        },\n      })\n    }\n\n    return downloadFilenames.length > 0 ? downloadFilenames : null\n  }\n\n  async downloadRemoteSuccessCallback(urls: string[], restart = true) {\n    // Check if any URL is a Wikipedia download and handle it\n    for (const url of urls) {\n      if (url.includes('wikipedia_en_')) {\n        await this.onWikipediaDownloadComplete(url, true)\n      }\n    }\n\n    if (restart) {\n      // Check if there are any remaining ZIM download jobs before restarting\n      const { QueueService } = await import('./queue_service.js')\n      const queueService = new QueueService()\n      const queue = queueService.getQueue('downloads')\n\n      // Get all active and waiting jobs\n      const [activeJobs, waitingJobs] = await Promise.all([\n        queue.getActive(),\n        queue.getWaiting(),\n      ])\n\n      // Filter out completed jobs (progress === 100) to avoid race condition\n      // where this job itself is still in the active queue\n      const activeIncompleteJobs = activeJobs.filter((job) => {\n        const progress = typeof job.progress === 'number' ? job.progress : 0\n        return progress < 100\n      })\n\n      // Check if any remaining incomplete jobs are ZIM downloads\n      const allJobs = [...activeIncompleteJobs, ...waitingJobs]\n      const hasRemainingZimJobs = allJobs.some((job) => job.data.filetype === 'zim')\n\n      if (hasRemainingZimJobs) {\n        logger.info('[ZimService] Skipping container restart - more ZIM downloads pending')\n      } else {\n        // Restart KIWIX container to pick up new ZIM file\n        logger.info('[ZimService] No more ZIM downloads pending - restarting KIWIX container')\n        await this.dockerService\n          .affectContainer(SERVICE_NAMES.KIWIX, 'restart')\n          .catch((error) => {\n            logger.error(`[ZimService] Failed to restart KIWIX container:`, error) // Don't stop the download completion, just log the error.\n          })\n      }\n    }\n\n    // Create InstalledResource entries for downloaded files\n    for (const url of urls) {\n      // Skip Wikipedia files (managed separately)\n      if (url.includes('wikipedia_en_')) continue\n\n      const filename = url.split('/').pop()\n      if (!filename) continue\n\n      const parsed = CollectionManifestService.parseZimFilename(filename)\n      if (!parsed) continue\n\n      const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)\n      const stats = await getFileStatsIfExists(filepath)\n\n      try {\n        const { DateTime } = await import('luxon')\n        await InstalledResource.updateOrCreate(\n          { resource_id: parsed.resource_id, resource_type: 'zim' },\n          {\n            version: parsed.version,\n            url: url,\n            file_path: filepath,\n            file_size_bytes: stats ? Number(stats.size) : null,\n            installed_at: DateTime.now(),\n          }\n        )\n        logger.info(`[ZimService] Created InstalledResource entry for: ${parsed.resource_id}`)\n      } catch (error) {\n        logger.error(`[ZimService] Failed to create InstalledResource for ${filename}:`, error)\n      }\n    }\n  }\n\n  async delete(file: string): Promise<void> {\n    let fileName = file\n    if (!fileName.endsWith('.zim')) {\n      fileName += '.zim'\n    }\n\n    const basePath = resolve(join(process.cwd(), ZIM_STORAGE_PATH))\n    const fullPath = resolve(join(basePath, fileName))\n\n    // Prevent path traversal — resolved path must stay within the storage directory\n    if (!fullPath.startsWith(basePath + sep)) {\n      throw new Error('Invalid filename')\n    }\n\n    const exists = await getFileStatsIfExists(fullPath)\n    if (!exists) {\n      throw new Error('not_found')\n    }\n\n    await deleteFileIfExists(fullPath)\n\n    // Clean up InstalledResource entry\n    const parsed = CollectionManifestService.parseZimFilename(fileName)\n    if (parsed) {\n      await InstalledResource.query()\n        .where('resource_id', parsed.resource_id)\n        .where('resource_type', 'zim')\n        .delete()\n      logger.info(`[ZimService] Deleted InstalledResource entry for: ${parsed.resource_id}`)\n    }\n  }\n\n  // Wikipedia selector methods\n\n  async getWikipediaOptions(): Promise<WikipediaOption[]> {\n    try {\n      const response = await axios.get(WIKIPEDIA_OPTIONS_URL)\n      const data = response.data\n\n      const validated = await vine.validate({\n        schema: wikipediaOptionsFileSchema,\n        data,\n      })\n\n      return validated.options\n    } catch (error) {\n      logger.error(`[ZimService] Failed to fetch Wikipedia options:`, error)\n      throw new Error('Failed to fetch Wikipedia options')\n    }\n  }\n\n  async getWikipediaSelection(): Promise<WikipediaSelection | null> {\n    // Get the single row from wikipedia_selections (there should only ever be one)\n    return WikipediaSelection.query().first()\n  }\n\n  async getWikipediaState(): Promise<WikipediaState> {\n    const options = await this.getWikipediaOptions()\n    const selection = await this.getWikipediaSelection()\n\n    return {\n      options,\n      currentSelection: selection\n        ? {\n          optionId: selection.option_id,\n          status: selection.status,\n          filename: selection.filename,\n          url: selection.url,\n        }\n        : null,\n    }\n  }\n\n  async selectWikipedia(optionId: string): Promise<{ success: boolean; jobId?: string; message?: string }> {\n    const options = await this.getWikipediaOptions()\n    const selectedOption = options.find((opt) => opt.id === optionId)\n\n    if (!selectedOption) {\n      throw new Error(`Invalid Wikipedia option: ${optionId}`)\n    }\n\n    const currentSelection = await this.getWikipediaSelection()\n\n    // If same as currently installed, no action needed\n    if (currentSelection?.option_id === optionId && currentSelection.status === 'installed') {\n      return { success: true, message: 'Already installed' }\n    }\n\n    // Handle \"none\" option - delete current Wikipedia file and update DB\n    if (optionId === 'none') {\n      if (currentSelection?.filename) {\n        try {\n          await this.delete(currentSelection.filename)\n          logger.info(`[ZimService] Deleted Wikipedia file: ${currentSelection.filename}`)\n        } catch (error) {\n          // File might already be deleted, that's OK\n          logger.warn(`[ZimService] Could not delete Wikipedia file (may already be gone): ${currentSelection.filename}`)\n        }\n      }\n\n      // Update or create the selection record (always use first record)\n      if (currentSelection) {\n        currentSelection.option_id = 'none'\n        currentSelection.url = null\n        currentSelection.filename = null\n        currentSelection.status = 'none'\n        await currentSelection.save()\n      } else {\n        await WikipediaSelection.create({\n          option_id: 'none',\n          url: null,\n          filename: null,\n          status: 'none',\n        })\n      }\n\n      // Restart Kiwix to reflect the change\n      await this.dockerService\n        .affectContainer(SERVICE_NAMES.KIWIX, 'restart')\n        .catch((error) => {\n          logger.error(`[ZimService] Failed to restart Kiwix after Wikipedia removal:`, error)\n        })\n\n      return { success: true, message: 'Wikipedia removed' }\n    }\n\n    // Start download for the new Wikipedia option\n    if (!selectedOption.url) {\n      throw new Error('Selected Wikipedia option has no download URL')\n    }\n\n    // Check if already downloading\n    const existingJob = await RunDownloadJob.getByUrl(selectedOption.url)\n    if (existingJob) {\n      return { success: false, message: 'Download already in progress' }\n    }\n\n    // Extract filename from URL\n    const filename = selectedOption.url.split('/').pop()\n    if (!filename) {\n      throw new Error('Could not determine filename from URL')\n    }\n\n    const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)\n\n    // Update or create selection record to show downloading status\n    let selection: WikipediaSelection\n    if (currentSelection) {\n      currentSelection.option_id = optionId\n      currentSelection.url = selectedOption.url\n      currentSelection.filename = filename\n      currentSelection.status = 'downloading'\n      await currentSelection.save()\n      selection = currentSelection\n    } else {\n      selection = await WikipediaSelection.create({\n        option_id: optionId,\n        url: selectedOption.url,\n        filename: filename,\n        status: 'downloading',\n      })\n    }\n\n    // Dispatch download job\n    const result = await RunDownloadJob.dispatch({\n      url: selectedOption.url,\n      filepath,\n      timeout: 30000,\n      allowedMimeTypes: ZIM_MIME_TYPES,\n      forceNew: true,\n      filetype: 'zim',\n    })\n\n    if (!result || !result.job) {\n      // Revert status on failure to dispatch\n      selection.option_id = currentSelection?.option_id || 'none'\n      selection.url = currentSelection?.url || null\n      selection.filename = currentSelection?.filename || null\n      selection.status = currentSelection?.status || 'none'\n      await selection.save()\n      throw new Error('Failed to dispatch download job')\n    }\n\n    logger.info(`[ZimService] Started Wikipedia download for ${optionId}: ${filename}`)\n\n    return {\n      success: true,\n      jobId: result.job.id,\n      message: 'Download started',\n    }\n  }\n\n  async onWikipediaDownloadComplete(url: string, success: boolean): Promise<void> {\n    const selection = await this.getWikipediaSelection()\n\n    if (!selection || selection.url !== url) {\n      logger.warn(`[ZimService] Wikipedia download complete callback for unknown URL: ${url}`)\n      return\n    }\n\n    if (success) {\n      // Update status to installed\n      selection.status = 'installed'\n      await selection.save()\n\n      logger.info(`[ZimService] Wikipedia download completed successfully: ${selection.filename}`)\n\n      // Delete the old Wikipedia file if it exists and is different\n      // We need to find what was previously installed\n      const existingFiles = await this.list()\n      const wikipediaFiles = existingFiles.files.filter((f) =>\n        f.name.startsWith('wikipedia_en_') && f.name !== selection.filename\n      )\n\n      for (const oldFile of wikipediaFiles) {\n        try {\n          await this.delete(oldFile.name)\n          logger.info(`[ZimService] Deleted old Wikipedia file: ${oldFile.name}`)\n        } catch (error) {\n          logger.warn(`[ZimService] Could not delete old Wikipedia file: ${oldFile.name}`, error)\n        }\n      }\n    } else {\n      // Download failed - keep the selection record but mark as failed\n      selection.status = 'failed'\n      await selection.save()\n      logger.error(`[ZimService] Wikipedia download failed for: ${selection.filename}`)\n    }\n  }\n}\n"
  },
  {
    "path": "admin/app/utils/downloads.ts",
    "content": "import {\n  DoResumableDownloadParams,\n  DoResumableDownloadWithRetryParams,\n} from '../../types/downloads.js'\nimport axios from 'axios'\nimport { Transform } from 'stream'\nimport { deleteFileIfExists, ensureDirectoryExists, getFileStatsIfExists } from './fs.js'\nimport { createWriteStream } from 'fs'\nimport path from 'path'\n\n/**\n * Perform a resumable download with progress tracking\n * @param param0 - Download parameters. Leave allowedMimeTypes empty to skip mime type checking.\n * Otherwise, mime types should be in the format \"application/pdf\", \"image/png\", etc.\n * @returns Path to the downloaded file\n */\nexport async function doResumableDownload({\n  url,\n  filepath,\n  timeout = 30000,\n  signal,\n  onProgress,\n  onComplete,\n  forceNew = false,\n  allowedMimeTypes,\n}: DoResumableDownloadParams): Promise<string> {\n  const dirname = path.dirname(filepath)\n  await ensureDirectoryExists(dirname)\n\n  // Check if partial file exists for resume\n  let startByte = 0\n  let appendMode = false\n\n  const existingStats = await getFileStatsIfExists(filepath)\n  if (existingStats && !forceNew) {\n    startByte = existingStats.size\n    appendMode = true\n  }\n\n  // Get file info with HEAD request first\n  const headResponse = await axios.head(url, {\n    signal,\n    timeout,\n  })\n\n  const contentType = headResponse.headers['content-type'] || ''\n  const totalBytes = parseInt(headResponse.headers['content-length'] || '0')\n  const supportsRangeRequests = headResponse.headers['accept-ranges'] === 'bytes'\n\n  // If allowedMimeTypes is provided, check content type\n  if (allowedMimeTypes && allowedMimeTypes.length > 0) {\n    const isMimeTypeAllowed = allowedMimeTypes.some((mimeType) => contentType.includes(mimeType))\n    if (!isMimeTypeAllowed) {\n      throw new Error(`MIME type ${contentType} is not allowed`)\n    }\n  }\n\n  // If file is already complete and not forcing overwrite just return filepath\n  if (startByte === totalBytes && totalBytes > 0 && !forceNew) {\n    return filepath\n  }\n\n  // If server doesn't support range requests and we have a partial file, delete it\n  if (!supportsRangeRequests && startByte > 0) {\n    await deleteFileIfExists(filepath)\n    startByte = 0\n    appendMode = false\n  }\n\n  const headers: Record<string, string> = {}\n  if (supportsRangeRequests && startByte > 0) {\n    headers.Range = `bytes=${startByte}-`\n  }\n\n  const response = await axios.get(url, {\n    responseType: 'stream',\n    headers,\n    signal,\n    timeout,\n  })\n\n  if (response.status !== 200 && response.status !== 206) {\n    throw new Error(`Failed to download: HTTP ${response.status}`)\n  }\n\n  return new Promise((resolve, reject) => {\n    let downloadedBytes = startByte\n    let lastProgressTime = Date.now()\n    let lastDownloadedBytes = startByte\n\n    // Stall detection: if no data arrives for 5 minutes, abort the download\n    const STALL_TIMEOUT_MS = 5 * 60 * 1000\n    let stallTimer: ReturnType<typeof setTimeout> | null = null\n\n    const clearStallTimer = () => {\n      if (stallTimer) {\n        clearTimeout(stallTimer)\n        stallTimer = null\n      }\n    }\n\n    const resetStallTimer = () => {\n      clearStallTimer()\n      stallTimer = setTimeout(() => {\n        cleanup(new Error('Download stalled - no data received for 5 minutes'))\n      }, STALL_TIMEOUT_MS)\n    }\n\n    // Progress tracking stream to monitor data flow\n    const progressStream = new Transform({\n      transform(chunk: Buffer, _: any, callback: Function) {\n        downloadedBytes += chunk.length\n        resetStallTimer()\n\n        // Update progress tracking\n        const now = Date.now()\n        if (onProgress && now - lastProgressTime >= 500) {\n          lastProgressTime = now\n          lastDownloadedBytes = downloadedBytes\n          onProgress({\n            downloadedBytes,\n            totalBytes,\n            lastProgressTime,\n            lastDownloadedBytes,\n            url,\n          })\n        }\n\n        this.push(chunk)\n        callback()\n      },\n    })\n\n    const writeStream = createWriteStream(filepath, {\n      flags: appendMode ? 'a' : 'w',\n    })\n\n    // Handle errors and cleanup\n    const cleanup = (error?: Error) => {\n      clearStallTimer()\n      progressStream.destroy()\n      response.data.destroy()\n      writeStream.destroy()\n      if (error) {\n        reject(error)\n      }\n    }\n\n    response.data.on('error', cleanup)\n    progressStream.on('error', cleanup)\n    writeStream.on('error', cleanup)\n    writeStream.on('error', cleanup)\n\n    signal?.addEventListener('abort', () => {\n      cleanup(new Error('Download aborted'))\n    })\n\n    writeStream.on('finish', async () => {\n      clearStallTimer()\n      if (onProgress) {\n        onProgress({\n          downloadedBytes,\n          totalBytes,\n          lastProgressTime: Date.now(),\n          lastDownloadedBytes: downloadedBytes,\n          url,\n        })\n      }\n      if (onComplete) {\n        await onComplete(url, filepath)\n      }\n      resolve(filepath)\n    })\n\n    // Start stall timer and pipe: response -> progressStream -> writeStream\n    resetStallTimer()\n    response.data.pipe(progressStream).pipe(writeStream)\n  })\n}\n\nexport async function doResumableDownloadWithRetry({\n  url,\n  filepath,\n  signal,\n  timeout = 30000,\n  onProgress,\n  max_retries = 3,\n  retry_delay = 2000,\n  onAttemptError,\n  allowedMimeTypes,\n}: DoResumableDownloadWithRetryParams): Promise<string> {\n  const dirname = path.dirname(filepath)\n  await ensureDirectoryExists(dirname)\n\n  let attempt = 0\n  let lastError: Error | null = null\n\n  while (attempt < max_retries) {\n    try {\n      const result = await doResumableDownload({\n        url,\n        filepath,\n        signal,\n        timeout,\n        allowedMimeTypes,\n        onProgress,\n      })\n\n      return result // return on success\n    } catch (error) {\n      attempt++\n      lastError = error as Error\n\n      const isAborted = error.name === 'AbortError' || error.code === 'ABORT_ERR'\n      const isNetworkError =\n        error.code === 'ECONNRESET' || error.code === 'ENOTFOUND' || error.code === 'ETIMEDOUT'\n\n      onAttemptError?.(error, attempt)\n      if (isAborted) {\n        throw new Error(`Download aborted for URL: ${url}`)\n      }\n\n      if (attempt < max_retries && isNetworkError) {\n        await delay(retry_delay)\n        continue\n      }\n\n      // If max retries reached or non-retriable error, throw\n      if (attempt >= max_retries || !isNetworkError) {\n        throw error\n      }\n    }\n  }\n\n  // should not reach here, but TypeScript needs a return\n  throw lastError || new Error('Unknown error during download')\n}\n\nasync function delay(ms: number): Promise<void> {\n  return new Promise((resolve) => setTimeout(resolve, ms))\n}\n"
  },
  {
    "path": "admin/app/utils/fs.ts",
    "content": "import { mkdir, readdir, readFile, stat, unlink } from 'fs/promises'\nimport path, { join } from 'path'\nimport { FileEntry } from '../../types/files.js'\nimport { createReadStream } from 'fs'\nimport { LSBlockDevice, NomadDiskInfoRaw } from '../../types/system.js'\n\nexport const ZIM_STORAGE_PATH = '/storage/zim'\n\nexport async function listDirectoryContents(path: string): Promise<FileEntry[]> {\n  const entries = await readdir(path, { withFileTypes: true })\n  const results: FileEntry[] = []\n  for (const entry of entries) {\n    if (entry.isFile()) {\n      results.push({\n        type: 'file',\n        key: join(path, entry.name),\n        name: entry.name,\n      })\n    } else if (entry.isDirectory()) {\n      results.push({\n        type: 'directory',\n        prefix: join(path, entry.name),\n        name: entry.name,\n      })\n    }\n  }\n  return results\n}\n\nexport async function listDirectoryContentsRecursive(path: string): Promise<FileEntry[]> {\n  let results: FileEntry[] = []\n  const entries = await readdir(path, { withFileTypes: true })\n  for (const entry of entries) {\n    const fullPath = join(path, entry.name)\n    if (entry.isDirectory()) {\n      const subdirectoryContents = await listDirectoryContentsRecursive(fullPath)\n      results = results.concat(subdirectoryContents)\n    } else {\n      results.push({\n        type: 'file',\n        key: fullPath,\n        name: entry.name,\n      })\n    }\n  }\n  return results\n}\n\nexport async function ensureDirectoryExists(path: string): Promise<void> {\n  try {\n    await stat(path)\n  } catch (error) {\n    if (error.code === 'ENOENT') {\n      await mkdir(path, { recursive: true })\n    }\n  }\n}\n\nexport async function getFile(path: string, returnType: 'buffer'): Promise<Buffer | null>\nexport async function getFile(\n  path: string,\n  returnType: 'stream'\n): Promise<NodeJS.ReadableStream | null>\nexport async function getFile(path: string, returnType: 'string'): Promise<string | null>\nexport async function getFile(\n  path: string,\n  returnType: 'buffer' | 'string' | 'stream' = 'buffer'\n): Promise<Buffer | string | NodeJS.ReadableStream | null> {\n  try {\n    if (returnType === 'string') {\n      return await readFile(path, 'utf-8')\n    } else if (returnType === 'stream') {\n      return createReadStream(path)\n    }\n    return await readFile(path)\n  } catch (error) {\n    if (error.code === 'ENOENT') {\n      return null\n    }\n    throw error\n  }\n}\n\nexport async function getFileStatsIfExists(\n  path: string\n): Promise<{ size: number; modifiedTime: Date } | null> {\n  try {\n    const stats = await stat(path)\n    return {\n      size: stats.size,\n      modifiedTime: stats.mtime,\n    }\n  } catch (error) {\n    if (error.code === 'ENOENT') {\n      return null\n    }\n    throw error\n  }\n}\n\nexport async function deleteFileIfExists(path: string): Promise<void> {\n  try {\n    await unlink(path)\n  } catch (error) {\n    if (error.code !== 'ENOENT') {\n      throw error\n    }\n  }\n}\n\nexport function getAllFilesystems(\n  device: LSBlockDevice,\n  fsSize: NomadDiskInfoRaw['fsSize']\n): NomadDiskInfoRaw['fsSize'] {\n  const filesystems: NomadDiskInfoRaw['fsSize'] = []\n  const seen = new Set()\n\n  function traverse(dev: LSBlockDevice) {\n    // Try to find matching filesystem\n    const fs = fsSize.find((f) => matchesDevice(f.fs, dev.name))\n\n    if (fs && !seen.has(fs.fs)) {\n      filesystems.push(fs)\n      seen.add(fs.fs)\n    }\n\n    // Traverse children recursively\n    if (dev.children) {\n      dev.children.forEach((child) => traverse(child))\n    }\n  }\n\n  traverse(device)\n  return filesystems\n}\n\nexport function matchesDevice(fsPath: string, deviceName: string): boolean {\n  // Remove /dev/ and /dev/mapper/ prefixes\n  const normalized = fsPath.replace('/dev/mapper/', '').replace('/dev/', '')\n\n  // Direct match (covers /dev/sda1 ↔ sda1, /dev/nvme0n1p1 ↔ nvme0n1p1)\n  if (normalized === deviceName) {\n    return true\n  }\n\n  // LVM/device-mapper: e.g., /dev/mapper/ubuntu--vg-ubuntu--lv contains \"ubuntu--lv\"\n  if (fsPath.startsWith('/dev/mapper/') && fsPath.includes(deviceName)) {\n    return true\n  }\n\n  return false\n}\n\nexport function determineFileType(filename: string): 'image' | 'pdf' | 'text' | 'zim' | 'unknown' {\n  const ext = path.extname(filename).toLowerCase()\n  if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp'].includes(ext)) {\n    return 'image'\n  } else if (ext === '.pdf') {\n    return 'pdf'\n  } else if (['.txt', '.md', '.docx', '.rtf'].includes(ext)) {\n    return 'text'\n  } else if (ext === '.zim') {\n    return 'zim'\n  } else {\n    return 'unknown'\n  }\n}\n\n/**\n * Sanitize a filename by removing potentially dangerous characters.\n * @param filename The original filename\n * @returns The sanitized filename\n */\nexport function sanitizeFilename(filename: string): string {\n  return filename.replace(/[^a-zA-Z0-9._-]/g, '_')\n}"
  },
  {
    "path": "admin/app/utils/misc.ts",
    "content": "export function formatSpeed(bytesPerSecond: number): string {\n  if (bytesPerSecond < 1024) return `${bytesPerSecond.toFixed(0)} B/s`\n  if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`\n  return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s`\n}\n\nexport function toTitleCase(str: string): string {\n  return str\n    .toLowerCase()\n    .split(' ')\n    .map((word) => word.charAt(0).toUpperCase() + word.slice(1))\n    .join(' ')\n}\n\nexport function parseBoolean(value: any): boolean {\n  if (typeof value === 'boolean') return value\n  if (typeof value === 'string') {\n    const lower = value.toLowerCase()\n    return lower === 'true' || lower === '1'\n  }\n  if (typeof value === 'number') {\n    return value === 1\n  }\n  return false\n}"
  },
  {
    "path": "admin/app/utils/version.ts",
    "content": "/**\n * Compare two semantic version strings to determine if the first is newer than the second.\n * @param version1 - The version to check (e.g., \"1.25.0\")\n * @param version2 - The current version (e.g., \"1.24.0\")\n * @returns true if version1 is newer than version2\n */\nexport function isNewerVersion(version1: string, version2: string, includePreReleases = false): boolean {\n  const normalize = (v: string) => v.replace(/^v/, '')\n  const [base1, pre1] = normalize(version1).split('-')\n  const [base2, pre2] = normalize(version2).split('-')\n\n  // If pre-releases are not included and version1 is a pre-release, don't consider it newer\n  if (!includePreReleases && pre1) {\n    return false\n  }\n\n  const v1Parts = base1.split('.').map((p) => parseInt(p, 10) || 0)\n  const v2Parts = base2.split('.').map((p) => parseInt(p, 10) || 0)\n\n  const maxLen = Math.max(v1Parts.length, v2Parts.length)\n  for (let i = 0; i < maxLen; i++) {\n    const a = v1Parts[i] || 0\n    const b = v2Parts[i] || 0\n    if (a > b) return true\n    if (a < b) return false\n  }\n\n  // Base versions equal — GA > RC, RC.n+1 > RC.n\n  if (!pre1 && pre2) return true // v1 is GA, v2 is RC → v1 is newer\n  if (pre1 && !pre2) return false // v1 is RC, v2 is GA → v2 is newer\n  if (!pre1 && !pre2) return false // both GA, equal\n\n  // Both prerelease: compare numeric suffix (e.g. \"rc.2\" vs \"rc.1\")\n  const pre1Num = parseInt(pre1.split('.')[1], 10) || 0\n  const pre2Num = parseInt(pre2.split('.')[1], 10) || 0\n  return pre1Num > pre2Num\n}\n\n/**\n * Parse the major version number from a tag string.\n * Strips the 'v' prefix if present.\n * @param tag - Version tag (e.g., \"v3.8.1\", \"10.19.4\")\n * @returns The major version number\n */\nexport function parseMajorVersion(tag: string): number {\n  const normalized = tag.replace(/^v/, '')\n  const major = parseInt(normalized.split('.')[0], 10)\n  return isNaN(major) ? 0 : major\n}\n"
  },
  {
    "path": "admin/app/validators/benchmark.ts",
    "content": "import vine from '@vinejs/vine'\n\nexport const runBenchmarkValidator = vine.compile(\n  vine.object({\n    benchmark_type: vine.enum(['full', 'system', 'ai']).optional(),\n  })\n)\n\nexport const submitBenchmarkValidator = vine.compile(\n  vine.object({\n    benchmark_id: vine.string().optional(),\n  })\n)\n"
  },
  {
    "path": "admin/app/validators/chat.ts",
    "content": "import vine from '@vinejs/vine'\n\nexport const createSessionSchema = vine.compile(\n  vine.object({\n    title: vine.string().trim().minLength(1).maxLength(200),\n    model: vine.string().trim().optional(),\n  })\n)\n\nexport const updateSessionSchema = vine.compile(\n  vine.object({\n    title: vine.string().trim().minLength(1).maxLength(200).optional(),\n    model: vine.string().trim().optional(),\n  })\n)\n\nexport const addMessageSchema = vine.compile(\n  vine.object({\n    role: vine.enum(['system', 'user', 'assistant'] as const),\n    content: vine.string().trim().minLength(1),\n  })\n)"
  },
  {
    "path": "admin/app/validators/common.ts",
    "content": "import vine from '@vinejs/vine'\n\n/**\n * Checks whether a URL points to a loopback or link-local address.\n * Used to prevent SSRF — the server should not fetch from localhost\n * or link-local/metadata endpoints (e.g. cloud instance metadata at 169.254.x.x).\n *\n * RFC1918 private ranges (10.x, 172.16-31.x, 192.168.x) are intentionally\n * ALLOWED because NOMAD is a LAN appliance and users may host content\n * mirrors on their local network.\n *\n * Throws an error if the URL is a loopback or link-local address.\n */\nexport function assertNotPrivateUrl(urlString: string): void {\n  const parsed = new URL(urlString)\n  const hostname = parsed.hostname.toLowerCase()\n\n  const blockedPatterns = [\n    /^localhost$/,\n    /^127\\.\\d+\\.\\d+\\.\\d+$/,\n    /^0\\.0\\.0\\.0$/,\n    /^169\\.254\\.\\d+\\.\\d+$/, // Link-local / cloud metadata\n    /^\\[::1\\]$/,\n    /^\\[?fe80:/i, // IPv6 link-local\n  ]\n\n  if (blockedPatterns.some((re) => re.test(hostname))) {\n    throw new Error(`Download URL must not point to a loopback or link-local address: ${hostname}`)\n  }\n}\n\nexport const remoteDownloadValidator = vine.compile(\n  vine.object({\n    url: vine\n      .string()\n      .url({ require_tld: false }) // Allow LAN URLs (e.g. http://my-nas:8080/file.zim)\n      .trim(),\n  })\n)\n\nexport const remoteDownloadWithMetadataValidator = vine.compile(\n  vine.object({\n    url: vine\n      .string()\n      .url({ require_tld: false }) // Allow LAN URLs\n      .trim(),\n    metadata: vine\n      .object({\n        title: vine.string().trim().minLength(1),\n        summary: vine.string().trim().optional(),\n        author: vine.string().trim().optional(),\n        size_bytes: vine.number().optional(),\n      })\n      .optional(),\n  })\n)\n\nexport const remoteDownloadValidatorOptional = vine.compile(\n  vine.object({\n    url: vine\n      .string()\n      .url({ require_tld: false }) // Allow LAN URLs\n      .trim()\n      .optional(),\n  })\n)\n\nexport const filenameParamValidator = vine.compile(\n  vine.object({\n    params: vine.object({\n      filename: vine.string().trim().minLength(1).maxLength(4096),\n    }),\n  })\n)\n\nexport const downloadCollectionValidator = vine.compile(\n  vine.object({\n    slug: vine.string(),\n  })\n)\n\nexport const downloadCategoryTierValidator = vine.compile(\n  vine.object({\n    categorySlug: vine.string().trim().minLength(1),\n    tierSlug: vine.string().trim().minLength(1),\n  })\n)\n\nexport const selectWikipediaValidator = vine.compile(\n  vine.object({\n    optionId: vine.string().trim().minLength(1),\n  })\n)\n\nconst resourceUpdateInfoBase = vine.object({\n  resource_id: vine.string().trim().minLength(1),\n  resource_type: vine.enum(['zim', 'map'] as const),\n  installed_version: vine.string().trim(),\n  latest_version: vine.string().trim().minLength(1),\n  download_url: vine.string().url({ require_tld: false }).trim(),\n})\n\nexport const applyContentUpdateValidator = vine.compile(resourceUpdateInfoBase)\n\nexport const applyAllContentUpdatesValidator = vine.compile(\n  vine.object({\n    updates: vine\n      .array(resourceUpdateInfoBase)\n      .minLength(1),\n  })\n)\n"
  },
  {
    "path": "admin/app/validators/curated_collections.ts",
    "content": "import vine from '@vinejs/vine'\n\n// ---- Versioned resource validators (with id + version) ----\n\nexport const specResourceValidator = vine.object({\n  id: vine.string(),\n  version: vine.string(),\n  title: vine.string(),\n  description: vine.string(),\n  url: vine.string().url(),\n  size_mb: vine.number().min(0).optional(),\n})\n\n// ---- ZIM Categories spec (versioned) ----\n\nexport const zimCategoriesSpecSchema = vine.object({\n  spec_version: vine.string(),\n  categories: vine.array(\n    vine.object({\n      name: vine.string(),\n      slug: vine.string(),\n      icon: vine.string(),\n      description: vine.string(),\n      language: vine.string().minLength(2).maxLength(5),\n      tiers: vine.array(\n        vine.object({\n          name: vine.string(),\n          slug: vine.string(),\n          description: vine.string(),\n          recommended: vine.boolean().optional(),\n          includesTier: vine.string().optional(),\n          resources: vine.array(specResourceValidator),\n        })\n      ),\n    })\n  ),\n})\n\n// ---- Maps spec (versioned) ----\n\nexport const mapsSpecSchema = vine.object({\n  spec_version: vine.string(),\n  collections: vine.array(\n    vine.object({\n      slug: vine.string(),\n      name: vine.string(),\n      description: vine.string(),\n      icon: vine.string(),\n      language: vine.string().minLength(2).maxLength(5),\n      resources: vine.array(specResourceValidator).minLength(1),\n    })\n  ).minLength(1),\n})\n\n// ---- Wikipedia spec (versioned) ----\n\nexport const wikipediaSpecSchema = vine.object({\n  spec_version: vine.string(),\n  options: vine.array(\n    vine.object({\n      id: vine.string(),\n      name: vine.string(),\n      description: vine.string(),\n      size_mb: vine.number().min(0),\n      url: vine.string().url().nullable(),\n      version: vine.string().nullable(),\n    })\n  ).minLength(1),\n})\n\n// ---- Wikipedia validators (used by ZimService) ----\n\nexport const wikipediaOptionSchema = vine.object({\n  id: vine.string(),\n  name: vine.string(),\n  description: vine.string(),\n  size_mb: vine.number().min(0),\n  url: vine.string().url().nullable(),\n})\n\nexport const wikipediaOptionsFileSchema = vine.object({\n  options: vine.array(wikipediaOptionSchema).minLength(1),\n})\n"
  },
  {
    "path": "admin/app/validators/download.ts",
    "content": "import vine from '@vinejs/vine'\n\nexport const downloadJobsByFiletypeSchema = vine.compile(\n  vine.object({\n    params: vine.object({\n      filetype: vine.string(),\n    }),\n  })\n)\n\nexport const modelNameSchema = vine.compile(\n  vine.object({\n    model: vine.string(),\n  })\n)\n"
  },
  {
    "path": "admin/app/validators/ollama.ts",
    "content": "import vine from '@vinejs/vine'\n\nexport const chatSchema = vine.compile(\n  vine.object({\n    model: vine.string().trim().minLength(1),\n    messages: vine.array(\n      vine.object({\n        role: vine.enum(['system', 'user', 'assistant'] as const),\n        content: vine.string(),\n      })\n    ),\n    stream: vine.boolean().optional(),\n    sessionId: vine.number().positive().optional(),\n  })\n)\n\nexport const getAvailableModelsSchema = vine.compile(\n  vine.object({\n    sort: vine.enum(['pulls', 'name'] as const).optional(),\n    recommendedOnly: vine.boolean().optional(),\n    query: vine.string().trim().optional(),\n    limit: vine.number().positive().optional(),\n    force: vine.boolean().optional(),\n  })\n)\n"
  },
  {
    "path": "admin/app/validators/rag.ts",
    "content": "import vine from '@vinejs/vine'\n\nexport const getJobStatusSchema = vine.compile(\n  vine.object({\n    filePath: vine.string(),\n  })\n)\n\nexport const deleteFileSchema = vine.compile(\n  vine.object({\n    source: vine.string(),\n  })\n)\n"
  },
  {
    "path": "admin/app/validators/settings.ts",
    "content": "import vine from \"@vinejs/vine\";\nimport { SETTINGS_KEYS } from \"../../constants/kv_store.js\";\n\n\nexport const updateSettingSchema = vine.compile(vine.object({\n    key: vine.enum(SETTINGS_KEYS),\n    value: vine.any().optional(),\n}))"
  },
  {
    "path": "admin/app/validators/system.ts",
    "content": "import vine from '@vinejs/vine'\n\nexport const installServiceValidator = vine.compile(\n  vine.object({\n    service_name: vine.string().trim(),\n  })\n)\n\nexport const affectServiceValidator = vine.compile(\n  vine.object({\n    service_name: vine.string().trim(),\n    action: vine.enum(['start', 'stop', 'restart']),\n  })\n)\n\nexport const subscribeToReleaseNotesValidator = vine.compile(\n  vine.object({\n    email: vine.string().email().trim(),\n  })\n)\n\nexport const checkLatestVersionValidator = vine.compile(\n  vine.object({\n    force: vine.boolean().optional(), // Optional flag to force bypassing cache and checking for updates immediately\n  })\n)\n\nexport const updateServiceValidator = vine.compile(\n  vine.object({\n    service_name: vine.string().trim(),\n    target_version: vine.string().trim(),\n  })\n)\n"
  },
  {
    "path": "admin/app/validators/zim.ts",
    "content": "import vine from '@vinejs/vine'\n\nexport const listRemoteZimValidator = vine.compile(\n  vine.object({\n    start: vine.number().min(0).optional(),\n    count: vine.number().min(1).max(100).optional(),\n    query: vine.string().optional(),\n  })\n)\n"
  },
  {
    "path": "admin/bin/console.ts",
    "content": "/*\n|--------------------------------------------------------------------------\n| Ace entry point\n|--------------------------------------------------------------------------\n|\n| The \"console.ts\" file is the entrypoint for booting the AdonisJS\n| command-line framework and executing commands.\n|\n| Commands do not boot the application, unless the currently running command\n| has \"options.startApp\" flag set to true.\n|\n*/\n\nimport 'reflect-metadata'\nimport { Ignitor, prettyPrintError } from '@adonisjs/core'\n\n/**\n * URL to the application root. AdonisJS need it to resolve\n * paths to file and directories for scaffolding commands\n */\nconst APP_ROOT = new URL('../', import.meta.url)\n\n/**\n * The importer is used to import files in context of the\n * application.\n */\nconst IMPORTER = (filePath: string) => {\n  if (filePath.startsWith('./') || filePath.startsWith('../')) {\n    return import(new URL(filePath, APP_ROOT).href)\n  }\n  return import(filePath)\n}\n\nnew Ignitor(APP_ROOT, { importer: IMPORTER })\n  .tap((app) => {\n    app.booting(async () => {\n      await import('#start/env')\n    })\n    app.listen('SIGTERM', () => app.terminate())\n    app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())\n  })\n  .ace()\n  .handle(process.argv.splice(2))\n  .catch((error) => {\n    process.exitCode = 1\n    prettyPrintError(error)\n  })\n"
  },
  {
    "path": "admin/bin/server.ts",
    "content": "/*\n|--------------------------------------------------------------------------\n| HTTP server entrypoint\n|--------------------------------------------------------------------------\n|\n| The \"server.ts\" file is the entrypoint for starting the AdonisJS HTTP\n| server. Either you can run this file directly or use the \"serve\"\n| command to run this file and monitor file changes\n|\n*/\n\nimport 'reflect-metadata'\nimport { Ignitor, prettyPrintError } from '@adonisjs/core'\n\n/**\n * URL to the application root. AdonisJS need it to resolve\n * paths to file and directories for scaffolding commands\n */\nconst APP_ROOT = new URL('../', import.meta.url)\n\n/**\n * The importer is used to import files in context of the\n * application.\n */\nconst IMPORTER = (filePath: string) => {\n  if (filePath.startsWith('./') || filePath.startsWith('../')) {\n    return import(new URL(filePath, APP_ROOT).href)\n  }\n  return import(filePath)\n}\n\nnew Ignitor(APP_ROOT, { importer: IMPORTER })\n  .tap((app) => {\n    app.booting(async () => {\n      await import('#start/env')\n    })\n    app.listen('SIGTERM', () => app.terminate())\n    app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())\n    app.ready(async () => {\n      try {\n        const collectionManifestService = new (await import('#services/collection_manifest_service')).CollectionManifestService()\n        await collectionManifestService.reconcileFromFilesystem()\n      } catch (error) {\n        // Catch and log any errors during reconciliation to prevent the server from crashing\n        console.error('Error during collection manifest reconciliation:', error)\n      }\n    })\n  })\n  .httpServer()\n  .start()\n  .catch((error) => {\n    process.exitCode = 1\n    prettyPrintError(error)\n  })\n"
  },
  {
    "path": "admin/bin/test.ts",
    "content": "/*\n|--------------------------------------------------------------------------\n| Test runner entrypoint\n|--------------------------------------------------------------------------\n|\n| The \"test.ts\" file is the entrypoint for running tests using Japa.\n|\n| Either you can run this file directly or use the \"test\"\n| command to run this file and monitor file changes.\n|\n*/\n\nprocess.env.NODE_ENV = 'test'\n\nimport 'reflect-metadata'\nimport { Ignitor, prettyPrintError } from '@adonisjs/core'\nimport { configure, processCLIArgs, run } from '@japa/runner'\n\n/**\n * URL to the application root. AdonisJS need it to resolve\n * paths to file and directories for scaffolding commands\n */\nconst APP_ROOT = new URL('../', import.meta.url)\n\n/**\n * The importer is used to import files in context of the\n * application.\n */\nconst IMPORTER = (filePath: string) => {\n  if (filePath.startsWith('./') || filePath.startsWith('../')) {\n    return import(new URL(filePath, APP_ROOT).href)\n  }\n  return import(filePath)\n}\n\nnew Ignitor(APP_ROOT, { importer: IMPORTER })\n  .tap((app) => {\n    app.booting(async () => {\n      await import('#start/env')\n    })\n    app.listen('SIGTERM', () => app.terminate())\n    app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())\n  })\n  .testRunner()\n  .configure(async (app) => {\n    const { runnerHooks, ...config } = await import('../tests/bootstrap.js')\n\n    processCLIArgs(process.argv.splice(2))\n    configure({\n      ...app.rcFile.tests,\n      ...config,\n      ...{\n        setup: runnerHooks.setup,\n        teardown: runnerHooks.teardown.concat([() => app.terminate()]),\n      },\n    })\n  })\n  .run(() => run())\n  .catch((error) => {\n    process.exitCode = 1\n    prettyPrintError(error)\n  })\n"
  },
  {
    "path": "admin/commands/benchmark/results.ts",
    "content": "import { BaseCommand, flags } from '@adonisjs/core/ace'\nimport type { CommandOptions } from '@adonisjs/core/types/ace'\n\nexport default class BenchmarkResults extends BaseCommand {\n  static commandName = 'benchmark:results'\n  static description = 'Display benchmark results'\n\n  @flags.boolean({ description: 'Show only the latest result', alias: 'l' })\n  declare latest: boolean\n\n  @flags.string({ description: 'Output format (table, json)', default: 'table' })\n  declare format: string\n\n  @flags.string({ description: 'Show specific benchmark by ID', alias: 'i' })\n  declare id: string\n\n  static options: CommandOptions = {\n    startApp: true,\n  }\n\n  async run() {\n    const { DockerService } = await import('#services/docker_service')\n    const { BenchmarkService } = await import('#services/benchmark_service')\n    const dockerService = new DockerService()\n    const benchmarkService = new BenchmarkService(dockerService)\n\n    try {\n      let results\n\n      if (this.id) {\n        const result = await benchmarkService.getResultById(this.id)\n        results = result ? [result] : []\n      } else if (this.latest) {\n        const result = await benchmarkService.getLatestResult()\n        results = result ? [result] : []\n      } else {\n        results = await benchmarkService.getAllResults()\n      }\n\n      if (results.length === 0) {\n        this.logger.info('No benchmark results found.')\n        this.logger.info('Run \"node ace benchmark:run\" to create a benchmark.')\n        return\n      }\n\n      if (this.format === 'json') {\n        console.log(JSON.stringify(results, null, 2))\n        return\n      }\n\n      // Table format\n      for (const result of results) {\n        this.logger.info('')\n        this.logger.info(`=== Benchmark ${result.benchmark_id} ===`)\n        this.logger.info(`Type: ${result.benchmark_type}`)\n        this.logger.info(`Date: ${result.created_at}`)\n        this.logger.info('')\n\n        this.logger.info('Hardware:')\n        this.logger.info(`  CPU: ${result.cpu_model}`)\n        this.logger.info(`  Cores: ${result.cpu_cores} physical, ${result.cpu_threads} threads`)\n        this.logger.info(`  RAM: ${Math.round(result.ram_bytes / (1024 * 1024 * 1024))} GB`)\n        this.logger.info(`  Disk: ${result.disk_type}`)\n        if (result.gpu_model) {\n          this.logger.info(`  GPU: ${result.gpu_model}`)\n        }\n        this.logger.info('')\n\n        this.logger.info('Scores:')\n        this.logger.info(`  CPU: ${result.cpu_score.toFixed(2)}`)\n        this.logger.info(`  Memory: ${result.memory_score.toFixed(2)}`)\n        this.logger.info(`  Disk Read: ${result.disk_read_score.toFixed(2)}`)\n        this.logger.info(`  Disk Write: ${result.disk_write_score.toFixed(2)}`)\n\n        if (result.ai_tokens_per_second) {\n          this.logger.info(`  AI Tokens/sec: ${result.ai_tokens_per_second.toFixed(2)}`)\n          this.logger.info(`  AI TTFT: ${result.ai_time_to_first_token?.toFixed(2)} ms`)\n        }\n        this.logger.info('')\n\n        this.logger.info(`NOMAD Score: ${result.nomad_score.toFixed(2)} / 100`)\n\n        if (result.submitted_to_repository) {\n          this.logger.info(`Submitted: Yes (${result.repository_id})`)\n        } else {\n          this.logger.info('Submitted: No')\n        }\n        this.logger.info('')\n      }\n\n      this.logger.info(`Total results: ${results.length}`)\n\n    } catch (error) {\n      this.logger.error(`Failed to retrieve results: ${error.message}`)\n      this.exitCode = 1\n    }\n  }\n}\n"
  },
  {
    "path": "admin/commands/benchmark/run.ts",
    "content": "import { BaseCommand, flags } from '@adonisjs/core/ace'\nimport type { CommandOptions } from '@adonisjs/core/types/ace'\n\nexport default class BenchmarkRun extends BaseCommand {\n  static commandName = 'benchmark:run'\n  static description = 'Run system and/or AI benchmarks to measure server performance'\n\n  @flags.boolean({ description: 'Run system benchmarks only (CPU, memory, disk)', alias: 's' })\n  declare systemOnly: boolean\n\n  @flags.boolean({ description: 'Run AI benchmark only', alias: 'a' })\n  declare aiOnly: boolean\n\n  @flags.boolean({ description: 'Submit results to repository after completion', alias: 'S' })\n  declare submit: boolean\n\n  static options: CommandOptions = {\n    startApp: true,\n  }\n\n  async run() {\n    const { DockerService } = await import('#services/docker_service')\n    const { BenchmarkService } = await import('#services/benchmark_service')\n    const dockerService = new DockerService()\n    const benchmarkService = new BenchmarkService(dockerService)\n\n    // Determine benchmark type\n    let benchmarkType: 'full' | 'system' | 'ai' = 'full'\n    if (this.systemOnly) {\n      benchmarkType = 'system'\n    } else if (this.aiOnly) {\n      benchmarkType = 'ai'\n    }\n\n    this.logger.info(`Starting ${benchmarkType} benchmark...`)\n    this.logger.info('')\n\n    try {\n      // Run the benchmark\n      let result\n      switch (benchmarkType) {\n        case 'system':\n          this.logger.info('Running system benchmarks (CPU, memory, disk)...')\n          result = await benchmarkService.runSystemBenchmarks()\n          break\n        case 'ai':\n          this.logger.info('Running AI benchmark...')\n          result = await benchmarkService.runAIBenchmark()\n          break\n        default:\n          this.logger.info('Running full benchmark suite...')\n          result = await benchmarkService.runFullBenchmark()\n      }\n\n      // Display results\n      this.logger.info('')\n      this.logger.success('Benchmark completed!')\n      this.logger.info('')\n\n      this.logger.info('=== Hardware Info ===')\n      this.logger.info(`CPU: ${result.cpu_model}`)\n      this.logger.info(`Cores: ${result.cpu_cores} physical, ${result.cpu_threads} threads`)\n      this.logger.info(`RAM: ${Math.round(result.ram_bytes / (1024 * 1024 * 1024))} GB`)\n      this.logger.info(`Disk Type: ${result.disk_type}`)\n      if (result.gpu_model) {\n        this.logger.info(`GPU: ${result.gpu_model}`)\n      }\n\n      this.logger.info('')\n      this.logger.info('=== Benchmark Scores ===')\n      this.logger.info(`CPU Score: ${result.cpu_score.toFixed(2)}`)\n      this.logger.info(`Memory Score: ${result.memory_score.toFixed(2)}`)\n      this.logger.info(`Disk Read Score: ${result.disk_read_score.toFixed(2)}`)\n      this.logger.info(`Disk Write Score: ${result.disk_write_score.toFixed(2)}`)\n\n      if (result.ai_tokens_per_second) {\n        this.logger.info(`AI Tokens/sec: ${result.ai_tokens_per_second.toFixed(2)}`)\n        this.logger.info(`AI Time to First Token: ${result.ai_time_to_first_token?.toFixed(2)} ms`)\n        this.logger.info(`AI Model: ${result.ai_model_used}`)\n      }\n\n      this.logger.info('')\n      this.logger.info(`NOMAD Score: ${result.nomad_score.toFixed(2)} / 100`)\n      this.logger.info('')\n      this.logger.info(`Benchmark ID: ${result.benchmark_id}`)\n\n      // Submit if requested\n      if (this.submit) {\n        this.logger.info('')\n        this.logger.info('Submitting results to repository...')\n        try {\n          const submitResult = await benchmarkService.submitToRepository(result.benchmark_id)\n          this.logger.success(`Results submitted! Repository ID: ${submitResult.repository_id}`)\n          this.logger.info(`Your percentile: ${submitResult.percentile}%`)\n        } catch (error) {\n          this.logger.error(`Failed to submit: ${error.message}`)\n        }\n      }\n\n    } catch (error) {\n      this.logger.error(`Benchmark failed: ${error.message}`)\n      this.exitCode = 1\n    }\n  }\n}\n"
  },
  {
    "path": "admin/commands/benchmark/submit.ts",
    "content": "import { BaseCommand, flags } from '@adonisjs/core/ace'\nimport type { CommandOptions } from '@adonisjs/core/types/ace'\n\nexport default class BenchmarkSubmit extends BaseCommand {\n  static commandName = 'benchmark:submit'\n  static description = 'Submit benchmark results to the community repository'\n\n  @flags.string({ description: 'Benchmark ID to submit (defaults to latest)', alias: 'i' })\n  declare benchmarkId: string\n\n  @flags.boolean({ description: 'Skip confirmation prompt', alias: 'y' })\n  declare yes: boolean\n\n  static options: CommandOptions = {\n    startApp: true,\n  }\n\n  async run() {\n    const { DockerService } = await import('#services/docker_service')\n    const { BenchmarkService } = await import('#services/benchmark_service')\n    const dockerService = new DockerService()\n    const benchmarkService = new BenchmarkService(dockerService)\n\n    try {\n      // Get the result to submit\n      const result = this.benchmarkId\n        ? await benchmarkService.getResultById(this.benchmarkId)\n        : await benchmarkService.getLatestResult()\n\n      if (!result) {\n        this.logger.error('No benchmark result found.')\n        this.logger.info('Run \"node ace benchmark:run\" first to create a benchmark.')\n        this.exitCode = 1\n        return\n      }\n\n      if (result.submitted_to_repository) {\n        this.logger.warning(`Benchmark ${result.benchmark_id} has already been submitted.`)\n        this.logger.info(`Repository ID: ${result.repository_id}`)\n        return\n      }\n\n      // Show what will be submitted\n      this.logger.info('')\n      this.logger.info('=== Data to be submitted ===')\n      this.logger.info('')\n      this.logger.info('Hardware Information:')\n      this.logger.info(`  CPU Model: ${result.cpu_model}`)\n      this.logger.info(`  CPU Cores: ${result.cpu_cores}`)\n      this.logger.info(`  CPU Threads: ${result.cpu_threads}`)\n      this.logger.info(`  RAM: ${Math.round(result.ram_bytes / (1024 * 1024 * 1024))} GB`)\n      this.logger.info(`  Disk Type: ${result.disk_type}`)\n      if (result.gpu_model) {\n        this.logger.info(`  GPU: ${result.gpu_model}`)\n      }\n      this.logger.info('')\n      this.logger.info('Benchmark Scores:')\n      this.logger.info(`  CPU Score: ${result.cpu_score.toFixed(2)}`)\n      this.logger.info(`  Memory Score: ${result.memory_score.toFixed(2)}`)\n      this.logger.info(`  Disk Read: ${result.disk_read_score.toFixed(2)}`)\n      this.logger.info(`  Disk Write: ${result.disk_write_score.toFixed(2)}`)\n      if (result.ai_tokens_per_second) {\n        this.logger.info(`  AI Tokens/sec: ${result.ai_tokens_per_second.toFixed(2)}`)\n        this.logger.info(`  AI TTFT: ${result.ai_time_to_first_token?.toFixed(2)} ms`)\n      }\n      this.logger.info(`  NOMAD Score: ${result.nomad_score.toFixed(2)}`)\n      this.logger.info('')\n      this.logger.info('Privacy Notice:')\n      this.logger.info('  - Only the information shown above will be submitted')\n      this.logger.info('  - No IP addresses, hostnames, or personal data is collected')\n      this.logger.info('  - Submissions are completely anonymous')\n      this.logger.info('')\n\n      // Confirm submission\n      if (!this.yes) {\n        const confirm = await this.prompt.confirm(\n          'Do you want to submit this benchmark to the community repository?'\n        )\n        if (!confirm) {\n          this.logger.info('Submission cancelled.')\n          return\n        }\n      }\n\n      // Submit\n      this.logger.info('Submitting benchmark...')\n      const submitResult = await benchmarkService.submitToRepository(result.benchmark_id)\n\n      this.logger.success('Benchmark submitted successfully!')\n      this.logger.info('')\n      this.logger.info(`Repository ID: ${submitResult.repository_id}`)\n      this.logger.info(`Your percentile: ${submitResult.percentile}%`)\n      this.logger.info('')\n      this.logger.info('Thank you for contributing to the NOMAD community!')\n\n    } catch (error) {\n      this.logger.error(`Submission failed: ${error.message}`)\n      this.exitCode = 1\n    }\n  }\n}\n"
  },
  {
    "path": "admin/commands/queue/work.ts",
    "content": "import { BaseCommand, flags } from '@adonisjs/core/ace'\nimport type { CommandOptions } from '@adonisjs/core/types/ace'\nimport { Worker } from 'bullmq'\nimport queueConfig from '#config/queue'\nimport { RunDownloadJob } from '#jobs/run_download_job'\nimport { DownloadModelJob } from '#jobs/download_model_job'\nimport { RunBenchmarkJob } from '#jobs/run_benchmark_job'\nimport { EmbedFileJob } from '#jobs/embed_file_job'\nimport { CheckUpdateJob } from '#jobs/check_update_job'\nimport { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job'\n\nexport default class QueueWork extends BaseCommand {\n  static commandName = 'queue:work'\n  static description = 'Start processing jobs from the queue'\n\n  @flags.string({ description: 'Queue name to process' })\n  declare queue: string\n\n  @flags.boolean({ description: 'Process all queues automatically' })\n  declare all: boolean\n\n  static options: CommandOptions = {\n    startApp: true,\n    staysAlive: true,\n  }\n\n  async run() {\n    // Validate that either --queue or --all is provided\n    if (!this.queue && !this.all) {\n      this.logger.error('You must specify either --queue=<name> or --all')\n      process.exit(1)\n    }\n\n    if (this.queue && this.all) {\n      this.logger.error('Cannot specify both --queue and --all flags')\n      process.exit(1)\n    }\n\n    const [jobHandlers, allQueues] = await this.loadJobHandlers()\n\n    // Determine which queues to process\n    const queuesToProcess = this.all ? Array.from(allQueues.values()) : [this.queue]\n\n    this.logger.info(`Starting workers for queues: ${queuesToProcess.join(', ')}`)\n\n    const workers: Worker[] = []\n\n    // Create a worker for each queue\n    for (const queueName of queuesToProcess) {\n      const worker = new Worker(\n        queueName,\n        async (job) => {\n          this.logger.info(`[${queueName}] Processing job: ${job.id} of type: ${job.name}`)\n          const jobHandler = jobHandlers.get(job.name)\n          if (!jobHandler) {\n            throw new Error(`No handler found for job: ${job.name}`)\n          }\n\n          return await jobHandler.handle(job)\n        },\n        {\n          connection: queueConfig.connection,\n          concurrency: this.getConcurrencyForQueue(queueName),\n          autorun: true,\n        }\n      )\n\n      worker.on('failed', async (job, err) => {\n        this.logger.error(`[${queueName}] Job failed: ${job?.id}, Error: ${err.message}`)\n\n        // If this was a Wikipedia download, mark it as failed in the DB\n        if (job?.data?.filetype === 'zim' && job?.data?.url?.includes('wikipedia_en_')) {\n          try {\n            const { DockerService } = await import('#services/docker_service')\n            const { ZimService } = await import('#services/zim_service')\n            const dockerService = new DockerService()\n            const zimService = new ZimService(dockerService)\n            await zimService.onWikipediaDownloadComplete(job.data.url, false)\n          } catch (e: any) {\n            this.logger.error(\n              `[${queueName}] Failed to update Wikipedia status: ${e.message}`\n            )\n          }\n        }\n      })\n\n      worker.on('completed', (job) => {\n        this.logger.info(`[${queueName}] Job completed: ${job.id}`)\n      })\n\n      workers.push(worker)\n      this.logger.info(`Worker started for queue: ${queueName}`)\n    }\n\n    // Schedule nightly update checks (idempotent, will persist over restarts)\n    await CheckUpdateJob.scheduleNightly()\n    await CheckServiceUpdatesJob.scheduleNightly()\n\n    // Graceful shutdown for all workers\n    process.on('SIGTERM', async () => {\n      this.logger.info('SIGTERM received. Shutting down workers...')\n      await Promise.all(workers.map((worker) => worker.close()))\n      this.logger.info('All workers shut down gracefully.')\n      process.exit(0)\n    })\n  }\n\n  private async loadJobHandlers(): Promise<[Map<string, any>, Map<string, string>]> {\n    const handlers = new Map<string, any>()\n    const queues = new Map<string, string>()\n\n    handlers.set(RunDownloadJob.key, new RunDownloadJob())\n    handlers.set(DownloadModelJob.key, new DownloadModelJob())\n    handlers.set(RunBenchmarkJob.key, new RunBenchmarkJob())\n    handlers.set(EmbedFileJob.key, new EmbedFileJob())\n    handlers.set(CheckUpdateJob.key, new CheckUpdateJob())\n    handlers.set(CheckServiceUpdatesJob.key, new CheckServiceUpdatesJob())\n\n    queues.set(RunDownloadJob.key, RunDownloadJob.queue)\n    queues.set(DownloadModelJob.key, DownloadModelJob.queue)\n    queues.set(RunBenchmarkJob.key, RunBenchmarkJob.queue)\n    queues.set(EmbedFileJob.key, EmbedFileJob.queue)\n    queues.set(CheckUpdateJob.key, CheckUpdateJob.queue)\n    queues.set(CheckServiceUpdatesJob.key, CheckServiceUpdatesJob.queue)\n\n    return [handlers, queues]\n  }\n\n  /**\n   * Get concurrency setting for a specific queue\n   * Can be customized per queue based on workload characteristics\n   */\n  private getConcurrencyForQueue(queueName: string): number {\n    const concurrencyMap: Record<string, number> = {\n      [RunDownloadJob.queue]: 3,\n      [DownloadModelJob.queue]: 2, // Lower concurrency for resource-intensive model downloads\n      [RunBenchmarkJob.queue]: 1, // Run benchmarks one at a time for accurate results\n      [EmbedFileJob.queue]: 2, // Lower concurrency for embedding jobs, can be resource intensive\n      [CheckUpdateJob.queue]: 1, // No need to run more than one update check at a time\n      default: 3,\n    }\n\n    return concurrencyMap[queueName] || concurrencyMap.default\n  }\n}\n"
  },
  {
    "path": "admin/config/app.ts",
    "content": "import env from '#start/env'\nimport app from '@adonisjs/core/services/app'\nimport { Secret } from '@adonisjs/core/helpers'\nimport { defineConfig } from '@adonisjs/core/http'\n\n/**\n * The app key is used for encrypting cookies, generating signed URLs,\n * and by the \"encryption\" module.\n *\n * The encryption module will fail to decrypt data if the key is lost or\n * changed. Therefore it is recommended to keep the app key secure.\n */\nexport const appKey = new Secret(env.get('APP_KEY'))\n\n/**\n * The configuration settings used by the HTTP server\n */\nexport const http = defineConfig({\n  generateRequestId: true,\n  allowMethodSpoofing: false,\n\n  /**\n   * Enabling async local storage will let you access HTTP context\n   * from anywhere inside your application.\n   */\n  useAsyncLocalStorage: false,\n\n  /**\n   * Manage cookies configuration. The settings for the session id cookie are\n   * defined inside the \"config/session.ts\" file.\n   */\n  cookie: {\n    domain: '',\n    path: '/',\n    maxAge: '2h',\n    httpOnly: true,\n    secure: app.inProduction,\n    sameSite: 'lax',\n  },\n})\n"
  },
  {
    "path": "admin/config/bodyparser.ts",
    "content": "import { defineConfig } from '@adonisjs/core/bodyparser'\n\nconst bodyParserConfig = defineConfig({\n  /**\n   * The bodyparser middleware will parse the request body\n   * for the following HTTP methods.\n   */\n  allowedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],\n\n  /**\n   * Config for the \"application/x-www-form-urlencoded\"\n   * content-type parser\n   */\n  form: {\n    convertEmptyStringsToNull: true,\n    types: ['application/x-www-form-urlencoded'],\n  },\n\n  /**\n   * Config for the JSON parser\n   */\n  json: {\n    convertEmptyStringsToNull: true,\n    types: [\n      'application/json',\n      'application/json-patch+json',\n      'application/vnd.api+json',\n      'application/csp-report',\n    ],\n  },\n\n  /**\n   * Config for the \"multipart/form-data\" content-type parser.\n   * File uploads are handled by the multipart parser.\n   */\n  multipart: {\n    /**\n     * Enabling auto process allows bodyparser middleware to\n     * move all uploaded files inside the tmp folder of your\n     * operating system\n     */\n    autoProcess: true,\n    convertEmptyStringsToNull: true,\n    processManually: [],\n\n    /**\n     * Maximum limit of data to parse including all files\n     * and fields\n     */\n    limit: '20mb',\n    types: ['multipart/form-data'],\n  },\n})\n\nexport default bodyParserConfig\n"
  },
  {
    "path": "admin/config/cors.ts",
    "content": "import { defineConfig } from '@adonisjs/cors'\n\n/**\n * Configuration options to tweak the CORS policy. The following\n * options are documented on the official documentation website.\n *\n * https://docs.adonisjs.com/guides/security/cors\n */\nconst corsConfig = defineConfig({\n  enabled: true,\n  origin: ['*'],\n  methods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE'],\n  headers: true,\n  exposeHeaders: [],\n  credentials: true,\n  maxAge: 90,\n})\n\nexport default corsConfig\n"
  },
  {
    "path": "admin/config/database.ts",
    "content": "import env from '#start/env'\nimport { defineConfig } from '@adonisjs/lucid'\n\nconst dbConfig = defineConfig({\n  connection: 'mysql',\n  connections: {\n    mysql: {\n      client: 'mysql2',\n      debug: env.get('NODE_ENV') === 'development',\n      connection: {\n        host: env.get('DB_HOST'),\n        port: env.get('DB_PORT') ?? 3306, // Default MySQL port\n        user: env.get('DB_USER'),\n        password: env.get('DB_PASSWORD'),\n        database: env.get('DB_DATABASE'),\n        ssl: env.get('DB_SSL') ?? true, // Default to true\n      },\n      migrations: {\n        naturalSort: true,\n        paths: ['database/migrations'],\n      },\n    },\n  },\n})\n\nexport default dbConfig"
  },
  {
    "path": "admin/config/hash.ts",
    "content": "import { defineConfig, drivers } from '@adonisjs/core/hash'\n\nconst hashConfig = defineConfig({\n  default: 'scrypt',\n\n  list: {\n    scrypt: drivers.scrypt({\n      cost: 16384,\n      blockSize: 8,\n      parallelization: 1,\n      maxMemory: 33554432,\n    }),\n  },\n})\n\nexport default hashConfig\n\n/**\n * Inferring types for the list of hashers you have configured\n * in your application.\n */\ndeclare module '@adonisjs/core/types' {\n  export interface HashersList extends InferHashers<typeof hashConfig> {}\n}\n"
  },
  {
    "path": "admin/config/inertia.ts",
    "content": "import KVStore from '#models/kv_store'\nimport { SystemService } from '#services/system_service'\nimport { defineConfig } from '@adonisjs/inertia'\nimport type { InferSharedProps } from '@adonisjs/inertia/types'\n\nconst inertiaConfig = defineConfig({\n  /**\n   * Path to the Edge view that will be used as the root view for Inertia responses\n   */\n  rootView: 'inertia_layout',\n\n  /**\n   * Data that should be shared with all rendered pages\n   */\n  sharedData: {\n    appVersion: () => SystemService.getAppVersion(),\n    environment: process.env.NODE_ENV || 'production',\n    aiAssistantName: async () => {\n      const customName = await KVStore.getValue('ai.assistantCustomName')\n      return (customName && customName.trim()) ? customName : 'AI Assistant'\n    },\n  },\n\n  /**\n   * Options for the server-side rendering\n   */\n  ssr: {\n    enabled: false,\n    entrypoint: 'inertia/app/ssr.tsx'\n  }\n})\n\nexport default inertiaConfig\n\ndeclare module '@adonisjs/inertia/types' {\n  export interface SharedProps extends InferSharedProps<typeof inertiaConfig> {}\n}"
  },
  {
    "path": "admin/config/logger.ts",
    "content": "import env from '#start/env'\nimport app from '@adonisjs/core/services/app'\nimport { defineConfig, targets } from '@adonisjs/core/logger'\n\nconst loggerConfig = defineConfig({\n  default: 'app',\n\n  /**\n   * The loggers object can be used to define multiple loggers.\n   * By default, we configure only one logger (named \"app\").\n   */\n  loggers: {\n    app: {\n      enabled: true,\n      name: env.get('APP_NAME'),\n      level: env.get('NODE_ENV') === 'production' ? env.get('LOG_LEVEL') : 'debug', // default to 'debug' in non-production envs\n      transport: {\n        targets:\n          targets()\n            .pushIf(!app.inProduction, targets.pretty())\n            .pushIf(app.inProduction, targets.file({ destination: \"/app/storage/logs/admin.log\", mkdir: true }))\n            .toArray(),\n      },\n    },\n  },\n})\n\nexport default loggerConfig\n\n/**\n * Inferring types for the list of loggers you have configured\n * in your application.\n */\ndeclare module '@adonisjs/core/types' {\n  export interface LoggersList extends InferLoggers<typeof loggerConfig> { }\n}\n"
  },
  {
    "path": "admin/config/queue.ts",
    "content": "import env from '#start/env'\n\nconst queueConfig = {\n  connection: {\n    host: env.get('REDIS_HOST'),\n    port: env.get('REDIS_PORT') ?? 6379,\n  },\n}\n\nexport default queueConfig\n"
  },
  {
    "path": "admin/config/session.ts",
    "content": "// import env from '#start/env'\n// import app from '@adonisjs/core/services/app'\n// import { defineConfig, stores } from '@adonisjs/session'\n\n// const sessionConfig = defineConfig({\n//   enabled: false,\n//   cookieName: 'adonis-session',\n\n//   /**\n//    * When set to true, the session id cookie will be deleted\n//    * once the user closes the browser.\n//    */\n//   clearWithBrowser: false,\n\n//   /**\n//    * Define how long to keep the session data alive without\n//    * any activity.\n//    */\n//   age: '2h',\n\n//   /**\n//    * Configuration for session cookie and the\n//    * cookie store\n//    */\n//   cookie: {\n//     path: '/',\n//     httpOnly: true,\n//     secure: app.inProduction,\n//     sameSite: 'lax',\n//   },\n\n//   /**\n//    * The store to use. Make sure to validate the environment\n//    * variable in order to infer the store name without any\n//    * errors.\n//    */\n//   store: env.get('SESSION_DRIVER'),\n\n//   /**\n//    * List of configured stores. Refer documentation to see\n//    * list of available stores and their config.\n//    */\n//   stores: {\n//     cookie: stores.cookie(),\n//   },\n// })\n\n// export default sessionConfig\n"
  },
  {
    "path": "admin/config/shield.ts",
    "content": "import { defineConfig } from '@adonisjs/shield'\n\nconst shieldConfig = defineConfig({\n  /**\n   * Configure CSP policies for your app. Refer documentation\n   * to learn more\n   */\n  csp: {\n    enabled: false,\n    directives: {},\n    reportOnly: false,\n  },\n\n  /**\n   * Configure CSRF protection options. Refer documentation\n   * to learn more\n   */\n  csrf: {\n    enabled: false, // TODO: Enable CSRF protection\n    exceptRoutes: [],\n    enableXsrfCookie: true,\n    methods: ['POST', 'PUT', 'PATCH', 'DELETE'],\n  },\n\n  /**\n   * Control how your website should be embedded inside\n   * iFrames\n   */\n  xFrame: {\n    enabled: true,\n    action: 'DENY',\n  },\n\n  /**\n   * Force browser to always use HTTPS\n   */\n  hsts: {\n    enabled: false, // TODO: Enable HSTS in production\n    maxAge: '180 days',\n  },\n\n  /**\n   * Disable browsers from sniffing the content type of a\n   * response and always rely on the \"content-type\" header.\n   */\n  contentTypeSniffing: {\n    enabled: true,\n  },\n})\n\nexport default shieldConfig\n"
  },
  {
    "path": "admin/config/static.ts",
    "content": "import { defineConfig } from '@adonisjs/static'\n\n/**\n * Configuration options to tweak the static files middleware.\n * The complete set of options are documented on the\n * official documentation website.\n *\n * https://docs.adonisjs.com/guides/static-assets\n */\nconst staticServerConfig = defineConfig({\n  enabled: true,\n  etag: true,\n  lastModified: true,\n  dotFiles: 'ignore',\n  acceptRanges: true,\n})\n\nexport default staticServerConfig\n"
  },
  {
    "path": "admin/config/transmit.ts",
    "content": "import env from '#start/env'\nimport { defineConfig } from '@adonisjs/transmit'\nimport { redis } from '@adonisjs/transmit/transports'\n\nexport default defineConfig({\n  pingInterval: '30s',\n  transport: {\n    driver: redis({\n      host: env.get('REDIS_HOST'),\n      port: env.get('REDIS_PORT'),\n      keyPrefix: 'transmit:',\n    })\n  }\n})"
  },
  {
    "path": "admin/config/vite.ts",
    "content": "import { defineConfig } from '@adonisjs/vite'\n\nconst viteBackendConfig = defineConfig({\n  /**\n   * The output of vite will be written inside this\n   * directory. The path should be relative from\n   * the application root.\n   */\n  buildDirectory: 'public/assets',\n\n  /**\n   * The path to the manifest file generated by the\n   * \"vite build\" command.\n   */\n  manifestFile: 'public/assets/.vite/manifest.json',\n\n  /**\n   * Feel free to change the value of the \"assetsUrl\" to\n   * point to a CDN in production.\n   */\n  assetsUrl: '/assets',\n\n  scriptAttributes: {\n    defer: true,\n  },\n})\n\nexport default viteBackendConfig\n"
  },
  {
    "path": "admin/constants/broadcast.ts",
    "content": "\nexport const BROADCAST_CHANNELS = {\n    BENCHMARK_PROGRESS: 'benchmark-progress',\n    OLLAMA_MODEL_DOWNLOAD: 'ollama-model-download',\n    SERVICE_INSTALLATION: 'service-installation',\n    SERVICE_UPDATES: 'service-updates',\n}"
  },
  {
    "path": "admin/constants/kv_store.ts",
    "content": "import { KVStoreKey } from \"../types/kv_store.js\";\n\nexport const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'ui.theme', 'system.earlyAccess', 'ai.assistantCustomName'];"
  },
  {
    "path": "admin/constants/misc.ts",
    "content": "\nexport const NOMAD_API_DEFAULT_BASE_URL = 'https://api.projectnomad.us'"
  },
  {
    "path": "admin/constants/ollama.ts",
    "content": "import { NomadOllamaModel } from '../types/ollama.js'\n\n/**\n * Fallback basic recommended Ollama models in case fetching from the service fails.\n */\nexport const FALLBACK_RECOMMENDED_OLLAMA_MODELS: NomadOllamaModel[] = [\n  {\n    name: 'llama3.1',\n    description:\n      'Llama 3.1 is a new state-of-the-art model from Meta available in 8B, 70B and 405B parameter sizes.',\n    estimated_pulls: '109.3M',\n    id: '9fe9c575-e77e-4a51-a743-07359458ee71',\n    first_seen: '2026-01-28T23:37:31.000+00:00',\n    model_last_updated: '1 year ago',\n    tags: [\n      {\n        name: 'llama3.1:8b-text-q4_1',\n        size: '5.1 GB',\n        context: '128k',\n        input: 'Text',\n        cloud: false,\n        thinking: false\n      },\n    ],\n  },\n  {\n    name: 'deepseek-r1',\n    description:\n      'DeepSeek-R1 is a family of open reasoning models with performance approaching that of leading models, such as O3 and Gemini 2.5 Pro.',\n    estimated_pulls: '77.2M',\n    id: '0b566560-68a6-4964-b0d4-beb3ab1ad694',\n    first_seen: '2026-01-28T23:37:31.000+00:00',\n    model_last_updated: '7 months ago',\n    tags: [\n      {\n        name: 'deepseek-r1:1.5b',\n        size: '1.1 GB',\n        context: '128k',\n        input: 'Text',\n        cloud: false,\n        thinking: true\n      },\n    ],\n  },\n  {\n    name: 'llama3.2',\n    description: \"Meta's Llama 3.2 goes small with 1B and 3B models.\",\n    estimated_pulls: '54.7M',\n    id: 'c9a1bc23-b290-4501-a913-f7c9bb39c3ad',\n    first_seen: '2026-01-28T23:37:31.000+00:00',\n    model_last_updated: '1 year ago',\n    tags: [\n      {\n        name: 'llama3.2:1b-text-q2_K',\n        size: '581 MB',\n        context: '128k',\n        input: 'Text',\n        cloud: false,\n        thinking: false\n      },\n    ],\n  },\n]\n\nexport const DEFAULT_QUERY_REWRITE_MODEL = 'qwen2.5:3b' // default to qwen2.5 for query rewriting with good balance of text task performance and resource usage\n\n/**\n * Adaptive RAG context limits based on model size.\n * Smaller models get overwhelmed with too much context, so we cap it.\n */\nexport const RAG_CONTEXT_LIMITS: { maxParams: number; maxResults: number; maxTokens: number }[] = [\n  { maxParams: 3, maxResults: 2, maxTokens: 1000 },   // 1-3B models\n  { maxParams: 8, maxResults: 4, maxTokens: 2500 },   // 4-8B models\n  { maxParams: Infinity, maxResults: 5, maxTokens: 0 }, // 13B+ (no cap)\n]\n\nexport const SYSTEM_PROMPTS = {\n  default: `\n Format all responses using markdown for better readability. Vanilla markdown or GitHub-flavored markdown is preferred.\n - Use **bold** and *italic* for emphasis.\n - Use code blocks with language identifiers for code snippets.\n - Use headers (##, ###) to organize longer responses.\n - Use bullet points or numbered lists for clarity.\n - Use tables when presenting structured data.\n`,\n  rag_context: (context: string) => `\nYou have access to relevant information from the knowledge base. This context has been retrieved based on semantic similarity to the user's question.\n\n[Knowledge Base Context]\n${context}\n\nIMPORTANT INSTRUCTIONS:\n1. If the user's question is directly related to the context above, use this information to provide accurate, detailed answers.\n2. Always cite or reference the context when using it (e.g., \"According to the information available...\" or \"Based on the knowledge base...\").\n3. If the context is only partially relevant, combine it with your general knowledge but be clear about what comes from the knowledge base.\n4. If the context is not relevant to the user's question, you can respond using your general knowledge without forcing the context into your answer. Do not mention the context if it's not relevant.\n5. Never fabricate information that isn't in the context or your training data.\n6. If you're unsure or you don't have enough information to answer the user's question, acknowledge the limitations.\n\nFormat your response using markdown for readability.\n`,\n  chat_suggestions: `\nYou are a helpful assistant that generates conversation starter suggestions for a survivalist/prepper using an AI assistant.\n\nProvide exactly 3 conversation starter topics as direct questions that someone would ask.\nThese should be clear, complete questions that can start meaningful conversations.\n\nExamples of good suggestions:\n- \"How do I purify water in an emergency?\"\n- \"What are the best foods for long-term storage?\"\n- \"Help me create a 72-hour emergency kit\"\n\nDo NOT use:\n- Follow-up questions seeking clarification\n- Vague or incomplete suggestions\n- Questions that assume prior context\n- Statements that are not suggestions themselves, such as praise for asking the question\n- Direct questions or commands to the user\n\nReturn ONLY the 3 suggestions as a comma-separated list with no additional text, formatting, numbering, or quotation marks.\nThe suggestions should be in title case.\nEnsure that your suggestions are comma-seperated with no conjunctions like \"and\" or \"or\".\nDo not use line breaks, new lines, or extra spacing to separate the suggestions.\nFormat: suggestion1, suggestion2, suggestion3\n`,\n  title_generation: `You are a title generator. Given the start of a conversation, generate a concise, descriptive title under 50 characters. Return ONLY the title text with no quotes, punctuation wrapping, or extra formatting.`,\n  query_rewrite: `\nYou are a query rewriting assistant. Your task is to reformulate the user's latest question to include relevant context from the conversation history.\n\nGiven the conversation history, rewrite the user's latest question to be a standalone, context-aware search query that will retrieve the most relevant information.\n\nRules:\n1. Keep the rewritten query concise (under 150 words)\n2. Include key entities, topics, and context from previous messages\n3. Make it a clear, searchable query\n4. Do NOT answer the question - only rewrite the user's query to be more effective for retrieval\n5. Output ONLY the rewritten query, nothing else\n\nExamples:\n\nConversation:\nUser: \"How do I install Gentoo?\"\nAssistant: [detailed installation guide]\nUser: \"Is an internet connection required to install?\"\n\nRewritten Query: \"Is an internet connection required to install Gentoo Linux?\"\n\n---\n\nConversation:\nUser: \"What's the best way to preserve meat?\"\nAssistant: [preservation methods]\nUser: \"How long does it last?\"\n\nRewritten Query: \"How long does preserved meat last using curing or smoking methods?\"\n`,\n}\n"
  },
  {
    "path": "admin/constants/service_names.ts",
    "content": "export const SERVICE_NAMES = {\n  KIWIX: 'nomad_kiwix_server',\n  OLLAMA: 'nomad_ollama',\n  QDRANT: 'nomad_qdrant',\n  CYBERCHEF: 'nomad_cyberchef',\n  FLATNOTES: 'nomad_flatnotes',\n  KOLIBRI: 'nomad_kolibri',\n}\n"
  },
  {
    "path": "admin/constants/zim_extraction.ts",
    "content": "\nexport const HTML_SELECTORS_TO_REMOVE = [\n    'script',\n    'style',\n    'nav',\n    'header',\n    'footer',\n    'noscript',\n    'iframe',\n    'svg',\n    '.navbox',\n    '.sidebar',\n    '.infobox',\n    '.mw-editsection',\n    '.reference',\n    '.reflist',\n    '.toc',\n    '.noprint',\n    '.mw-jump-link',\n    '.mw-headline-anchor',\n    '[role=\"navigation\"]',\n    '.navbar',\n    '.hatnote',\n    '.ambox',\n    '.sistersitebox',\n    '.portal',\n    '#coordinates',\n    '.geo-nondefault',\n    '.authority-control',\n]\n\n// Common heading names that usually don't have meaningful content under them\nexport const NON_CONTENT_HEADING_PATTERNS = [\n    /^see also$/i,\n    /^references$/i,\n    /^external links$/i,\n    /^further reading$/i,\n    /^notes$/i,\n    /^bibliography$/i,\n    /^navigation$/i,\n]\n\n/**\n * Batch size for processing ZIM articles to prevent lock timeout errors.\n * Processing 50 articles at a time balances throughput with job duration.\n * Typical processing time: 2-5 minutes per batch depending on article complexity.\n */\nexport const ZIM_BATCH_SIZE = 50"
  },
  {
    "path": "admin/database/migrations/1751086751801_create_services_table.ts",
    "content": "import { BaseSchema } from '@adonisjs/lucid/schema'\n\nexport default class extends BaseSchema {\n  protected tableName = 'services'\n\n  async up() {\n    this.schema.createTable(this.tableName, (table) => {\n      table.increments('id')\n      table.string('service_name').unique().notNullable()\n      table.string('container_image').notNullable()\n      table.string('container_command').nullable()\n      table.json('container_config').nullable()\n      table.boolean('installed').defaultTo(false)\n      table.string('depends_on').nullable().references('service_name').inTable(this.tableName).onDelete('SET NULL')\n      table.boolean('is_dependency_service').defaultTo(false)\n      table.string('ui_location')\n      table.json('metadata').nullable()\n      table.timestamp('created_at')\n      table.timestamp('updated_at')\n    })\n  }\n\n  async down() {\n    this.schema.dropTable(this.tableName)\n  }\n}"
  },
  {
    "path": "admin/database/migrations/1763499145832_update_services_table.ts",
    "content": "import { BaseSchema } from '@adonisjs/lucid/schema'\n\nexport default class extends BaseSchema {\n  protected tableName = 'services'\n\n  async up() {\n    this.schema.alterTable(this.tableName, (table) => {\n      table.string('friendly_name').nullable()\n      table.string('description').nullable()\n    })\n  }\n\n  async down() {\n    this.schema.alterTable(this.tableName, (table) => {\n      table.dropColumn('friendly_name')\n      table.dropColumn('description')\n    })\n  }\n}"
  },
  {
    "path": "admin/database/migrations/1764912210741_create_curated_collections_table.ts",
    "content": "import { BaseSchema } from '@adonisjs/lucid/schema'\n\nexport default class extends BaseSchema {\n  protected tableName = 'curated_collections'\n\n  async up() {\n    this.schema.createTable(this.tableName, (table) => {\n      table.string('slug').primary()\n      table.enum('type', ['zim', 'map']).notNullable()\n      table.string('name').notNullable()\n      table.text('description').notNullable()\n      table.string('icon').notNullable()\n      table.string('language').notNullable()\n      table.timestamp('created_at')\n      table.timestamp('updated_at')\n    })\n  }\n\n  async down() {\n    this.schema.dropTable(this.tableName)\n  }\n}\n"
  },
  {
    "path": "admin/database/migrations/1764912270123_create_curated_collection_resources_table.ts",
    "content": "import { BaseSchema } from '@adonisjs/lucid/schema'\n\nexport default class extends BaseSchema {\n  protected tableName = 'curated_collection_resources'\n\n  async up() {\n    this.schema.createTable(this.tableName, (table) => {\n      table.increments('id')\n      table.string('curated_collection_slug').notNullable().references('slug').inTable('curated_collections').onDelete('CASCADE')\n      table.string('title').notNullable()\n      table.string('url').notNullable()\n      table.text('description').notNullable()\n      table.integer('size_mb').notNullable()\n      table.boolean('downloaded').notNullable().defaultTo(false)\n      table.timestamp('created_at')\n      table.timestamp('updated_at')\n    })\n  }\n\n  async down() {\n    this.schema.dropTable(this.tableName)\n  }\n}"
  },
  {
    "path": "admin/database/migrations/1768170944482_update_services_add_installation_statuses_table.ts",
    "content": "import { BaseSchema } from '@adonisjs/lucid/schema'\n\nexport default class extends BaseSchema {\n  protected tableName = 'services'\n\n  async up() {\n    this.schema.alterTable(this.tableName, (table) => {\n      table.string('installation_status').defaultTo('idle').notNullable()\n    })\n  }\n\n  async down() {\n    this.schema.alterTable(this.tableName, (table) => {\n      table.dropColumn('installation_status')\n    })\n  }\n}"
  },
  {
    "path": "admin/database/migrations/1768453747522_update_services_add_icon.ts",
    "content": "import { BaseSchema } from '@adonisjs/lucid/schema'\n\nexport default class extends BaseSchema {\n  protected tableName = 'services'\n\n  async up() {\n    this.schema.alterTable(this.tableName, (table) => {\n      table.string('icon').nullable()\n    })\n  }\n\n  async down() {\n    this.schema.alterTable(this.tableName, (table) => {\n      table.dropColumn('icon')\n    })\n  }\n}\n"
  },
  {
    "path": "admin/database/migrations/1769097600001_create_benchmark_results_table.ts",
    "content": "import { BaseSchema } from '@adonisjs/lucid/schema'\n\nexport default class extends BaseSchema {\n  protected tableName = 'benchmark_results'\n\n  async up() {\n    this.schema.createTable(this.tableName, (table) => {\n      table.increments('id')\n      table.string('benchmark_id').unique().notNullable()\n      table.enum('benchmark_type', ['full', 'system', 'ai']).notNullable()\n\n      // Hardware information\n      table.string('cpu_model').notNullable()\n      table.integer('cpu_cores').notNullable()\n      table.integer('cpu_threads').notNullable()\n      table.bigInteger('ram_bytes').notNullable()\n      table.enum('disk_type', ['ssd', 'hdd', 'nvme', 'unknown']).notNullable()\n      table.string('gpu_model').nullable()\n\n      // System benchmark scores\n      table.float('cpu_score').notNullable()\n      table.float('memory_score').notNullable()\n      table.float('disk_read_score').notNullable()\n      table.float('disk_write_score').notNullable()\n\n      // AI benchmark scores (nullable for system-only benchmarks)\n      table.float('ai_tokens_per_second').nullable()\n      table.string('ai_model_used').nullable()\n      table.float('ai_time_to_first_token').nullable()\n\n      // Composite NOMAD score (0-100)\n      table.float('nomad_score').notNullable()\n\n      // Repository submission tracking\n      table.boolean('submitted_to_repository').defaultTo(false)\n      table.timestamp('submitted_at').nullable()\n      table.string('repository_id').nullable()\n\n      table.timestamp('created_at')\n      table.timestamp('updated_at')\n    })\n  }\n\n  async down() {\n    this.schema.dropTable(this.tableName)\n  }\n}\n"
  },
  {
    "path": "admin/database/migrations/1769097600002_create_benchmark_settings_table.ts",
    "content": "import { BaseSchema } from '@adonisjs/lucid/schema'\n\nexport default class extends BaseSchema {\n  protected tableName = 'benchmark_settings'\n\n  async up() {\n    this.schema.createTable(this.tableName, (table) => {\n      table.increments('id')\n      table.string('key').unique().notNullable()\n      table.text('value').nullable()\n      table.timestamp('created_at')\n      table.timestamp('updated_at')\n    })\n  }\n\n  async down() {\n    this.schema.dropTable(this.tableName)\n  }\n}\n"
  },
  {
    "path": "admin/database/migrations/1769300000001_add_powered_by_and_display_order_to_services.ts",
    "content": "import { BaseSchema } from '@adonisjs/lucid/schema'\n\nexport default class extends BaseSchema {\n  protected tableName = 'services'\n\n  async up() {\n    this.schema.alterTable(this.tableName, (table) => {\n      table.string('powered_by').nullable()\n      table.integer('display_order').nullable().defaultTo(100)\n    })\n  }\n\n  async down() {\n    this.schema.alterTable(this.tableName, (table) => {\n      table.dropColumn('powered_by')\n      table.dropColumn('display_order')\n    })\n  }\n}\n"
  },
  {
    "path": "admin/database/migrations/1769300000002_update_services_friendly_names.ts",
    "content": "import { BaseSchema } from '@adonisjs/lucid/schema'\n\nexport default class extends BaseSchema {\n  protected tableName = 'services'\n\n  async up() {\n    // Update existing services with new friendly names and powered_by values\n    await this.db.rawQuery(`\n      UPDATE services SET\n        friendly_name = 'Information Library',\n        powered_by = 'Kiwix',\n        display_order = 1,\n        description = 'Offline access to Wikipedia, medical references, how-to guides, and encyclopedias'\n      WHERE service_name = 'nomad_kiwix_serve'\n    `)\n\n    await this.db.rawQuery(`\n      UPDATE services SET\n        friendly_name = 'Education Platform',\n        powered_by = 'Kolibri',\n        display_order = 2,\n        description = 'Interactive learning platform with video courses and exercises'\n      WHERE service_name = 'nomad_kolibri'\n    `)\n\n    await this.db.rawQuery(`\n      UPDATE services SET\n        friendly_name = 'AI Assistant',\n        powered_by = 'Ollama',\n        ui_location = '/chat',\n        display_order = 3,\n        description = 'Local AI chat that runs entirely on your hardware - no internet required'\n      WHERE service_name = 'nomad_ollama'\n    `)\n\n    await this.db.rawQuery(`\n      UPDATE services SET\n        friendly_name = 'Notes',\n        powered_by = 'FlatNotes',\n        display_order = 10,\n        description = 'Simple note-taking app with local storage'\n      WHERE service_name = 'nomad_flatnotes'\n    `)\n\n    await this.db.rawQuery(`\n      UPDATE services SET\n        friendly_name = 'Data Tools',\n        powered_by = 'CyberChef',\n        display_order = 11,\n        description = 'Swiss Army knife for data encoding, encryption, and analysis'\n      WHERE service_name = 'nomad_cyberchef'\n    `)\n  }\n\n  async down() {\n    // Revert to original names\n    await this.db.rawQuery(`\n      UPDATE services SET\n        friendly_name = 'Kiwix',\n        powered_by = NULL,\n        display_order = NULL,\n        description = 'Offline Wikipedia, eBooks, and more'\n      WHERE service_name = 'nomad_kiwix_serve'\n    `)\n\n    await this.db.rawQuery(`\n      UPDATE services SET\n        friendly_name = 'Kolibri',\n        powered_by = NULL,\n        display_order = NULL,\n        description = 'An offline-first education platform for schools and learners'\n      WHERE service_name = 'nomad_kolibri'\n    `)\n\n    await this.db.rawQuery(`\n      UPDATE services SET\n        friendly_name = 'Ollama',\n        powered_by = NULL,\n        display_order = NULL,\n        description = 'Local AI chat that runs entirely on your hardware - no internet required'\n      WHERE service_name = 'nomad_ollama'\n    `)\n\n    await this.db.rawQuery(`\n      UPDATE services SET\n        friendly_name = 'FlatNotes',\n        powered_by = NULL,\n        display_order = NULL,\n        description = 'A simple note-taking app that stores all files locally'\n      WHERE service_name = 'nomad_flatnotes'\n    `)\n\n    await this.db.rawQuery(`\n      UPDATE services SET\n        friendly_name = 'CyberChef',\n        powered_by = NULL,\n        display_order = NULL,\n        description = 'The Cyber Swiss Army Knife - a web app for encryption, encoding, and data analysis'\n      WHERE service_name = 'nomad_cyberchef'\n    `)\n  }\n}\n"
  },
  {
    "path": "admin/database/migrations/1769324448000_add_builder_tag_to_benchmark_results.ts",
    "content": "import { BaseSchema } from '@adonisjs/lucid/schema'\n\nexport default class extends BaseSchema {\n  protected tableName = 'benchmark_results'\n\n  async up() {\n    this.schema.alterTable(this.tableName, (table) => {\n      table.string('builder_tag', 64).nullable()\n    })\n  }\n\n  async down() {\n    this.schema.alterTable(this.tableName, (table) => {\n      table.dropColumn('builder_tag')\n    })\n  }\n}\n"
  },
  {
    "path": "admin/database/migrations/1769400000001_create_installed_tiers_table.ts",
    "content": "import { BaseSchema } from '@adonisjs/lucid/schema'\n\nexport default class extends BaseSchema {\n  protected tableName = 'installed_tiers'\n\n  async up() {\n    this.schema.createTable(this.tableName, (table) => {\n      table.increments('id').primary()\n      table.string('category_slug').notNullable().unique()\n      table.string('tier_slug').notNullable()\n      table.timestamp('created_at')\n      table.timestamp('updated_at')\n    })\n  }\n\n  async down() {\n    this.schema.dropTable(this.tableName)\n  }\n}\n"
  },
  {
    "path": "admin/database/migrations/1769400000002_create_kv_store_table.ts",
    "content": "import { BaseSchema } from '@adonisjs/lucid/schema'\n\nexport default class extends BaseSchema {\n  protected tableName = 'kv_store'\n\n  async up() {\n    this.schema.createTable(this.tableName, (table) => {\n      table.increments('id')\n      table.string('key').unique().notNullable()\n      table.text('value').nullable()\n      table.timestamp('created_at')\n      table.timestamp('updated_at')\n    })\n  }\n\n  async down() {\n    this.schema.dropTable(this.tableName)\n  }\n}"
  },
  {
    "path": "admin/database/migrations/1769500000001_create_wikipedia_selection_table.ts",
    "content": "import { BaseSchema } from '@adonisjs/lucid/schema'\n\nexport default class extends BaseSchema {\n  protected tableName = 'wikipedia_selections'\n\n  async up() {\n    this.schema.createTable(this.tableName, (table) => {\n      table.increments('id').primary()\n      table.string('option_id').notNullable()\n      table.string('url').nullable()\n      table.string('filename').nullable()\n      table.enum('status', ['none', 'downloading', 'installed', 'failed']).defaultTo('none')\n      table.timestamp('created_at')\n      table.timestamp('updated_at')\n    })\n  }\n\n  async down() {\n    this.schema.dropTable(this.tableName)\n  }\n}\n"
  },
  {
    "path": "admin/database/migrations/1769646771604_create_create_chat_sessions_table.ts",
    "content": "import { BaseSchema } from '@adonisjs/lucid/schema'\n\nexport default class extends BaseSchema {\n  protected tableName = 'chat_sessions'\n\n  async up() {\n    this.schema.createTable(this.tableName, (table) => {\n      table.increments('id')\n      table.string('title').notNullable()\n      table.string('model').nullable()\n      table.timestamp('created_at')\n      table.timestamp('updated_at')\n    })\n  }\n\n  async down() {\n    this.schema.dropTable(this.tableName)\n  }\n}"
  },
  {
    "path": "admin/database/migrations/1769646798266_create_create_chat_messages_table.ts",
    "content": "import { BaseSchema } from '@adonisjs/lucid/schema'\n\nexport default class extends BaseSchema {\n  protected tableName = 'chat_messages'\n\n  async up() {\n    this.schema.createTable(this.tableName, (table) => {\n      table.increments('id')\n      table.integer('session_id').unsigned().references('id').inTable('chat_sessions').onDelete('CASCADE')\n      table.enum('role', ['system', 'user', 'assistant']).notNullable()\n      table.text('content').notNullable()\n      table.timestamp('created_at')\n      table.timestamp('updated_at')\n    })\n  }\n\n  async down() {\n    this.schema.dropTable(this.tableName)\n  }\n}"
  },
  {
    "path": "admin/database/migrations/1769700000001_create_zim_file_metadata_table.ts",
    "content": "import { BaseSchema } from '@adonisjs/lucid/schema'\n\nexport default class extends BaseSchema {\n  protected tableName = 'zim_file_metadata'\n\n  async up() {\n    this.schema.createTable(this.tableName, (table) => {\n      table.increments('id').primary()\n      table.string('filename').notNullable().unique()\n      table.string('title').notNullable()\n      table.text('summary').nullable()\n      table.string('author').nullable()\n      table.bigInteger('size_bytes').nullable()\n      table.timestamp('created_at')\n      table.timestamp('updated_at')\n    })\n  }\n\n  async down() {\n    this.schema.dropTable(this.tableName)\n  }\n}\n"
  },
  {
    "path": "admin/database/migrations/1770269324176_add_unique_constraint_to_curated_collection_resources_table.ts",
    "content": "import { BaseSchema } from '@adonisjs/lucid/schema'\n\nexport default class extends BaseSchema {\n  protected tableName = 'curated_collection_resources'\n\n  async up() {\n    this.schema.alterTable(this.tableName, (table) => {\n      table.unique(['curated_collection_slug', 'url'], {\n        indexName: 'curated_collection_resources_unique',\n      })\n    })\n  }\n\n  async down() {\n    this.schema.alterTable(this.tableName, (table) => {\n      table.dropUnique(['curated_collection_slug', 'url'], 'curated_collection_resources_unique')\n    })\n  }\n}"
  },
  {
    "path": "admin/database/migrations/1770273423670_drop_installed_tiers_table.ts",
    "content": "import { BaseSchema } from '@adonisjs/lucid/schema'\n\nexport default class extends BaseSchema {\n  protected tableName = 'installed_tiers'\n\n  async up() {\n    this.schema.dropTableIfExists(this.tableName)\n  }\n\n  async down() {\n    // Recreate the table if we need to rollback\n    this.schema.createTable(this.tableName, (table) => {\n      table.increments('id')\n      table.string('category_slug').notNullable().unique()\n      table.string('tier_slug').notNullable()\n      table.timestamp('created_at', { useTz: true })\n      table.timestamp('updated_at', { useTz: true })\n    })\n  }\n}"
  },
  {
    "path": "admin/database/migrations/1770849108030_create_create_collection_manifests_table.ts",
    "content": "import { BaseSchema } from '@adonisjs/lucid/schema'\n\nexport default class extends BaseSchema {\n  protected tableName = 'collection_manifests'\n\n  async up() {\n    this.schema.createTable(this.tableName, (table) => {\n      table.string('type').primary() // 'zim_categories' | 'maps' | 'wikipedia'\n      table.string('spec_version').notNullable()\n      table.json('spec_data').notNullable()\n      table.timestamp('fetched_at').notNullable()\n    })\n  }\n\n  async down() {\n    this.schema.dropTable(this.tableName)\n  }\n}\n"
  },
  {
    "path": "admin/database/migrations/1770849119787_create_create_installed_resources_table.ts",
    "content": "import { BaseSchema } from '@adonisjs/lucid/schema'\n\nexport default class extends BaseSchema {\n  protected tableName = 'installed_resources'\n\n  async up() {\n    this.schema.createTable(this.tableName, (table) => {\n      table.increments('id').primary()\n      table.string('resource_id').notNullable()\n      table.enum('resource_type', ['zim', 'map']).notNullable()\n      table.string('collection_ref').nullable()\n      table.string('version').notNullable()\n      table.string('url').notNullable()\n      table.string('file_path').notNullable()\n      table.bigInteger('file_size_bytes').nullable()\n      table.timestamp('installed_at').notNullable()\n\n      table.unique(['resource_id', 'resource_type'])\n    })\n  }\n\n  async down() {\n    this.schema.dropTable(this.tableName)\n  }\n}\n"
  },
  {
    "path": "admin/database/migrations/1770850092871_create_drop_legacy_curated_tables_table.ts",
    "content": "import { BaseSchema } from '@adonisjs/lucid/schema'\n\nexport default class extends BaseSchema {\n  async up() {\n    this.schema.dropTableIfExists('curated_collection_resources')\n    this.schema.dropTableIfExists('curated_collections')\n    this.schema.dropTableIfExists('zim_file_metadata')\n  }\n\n  async down() {\n    // These tables are legacy and intentionally not recreated\n  }\n}\n"
  },
  {
    "path": "admin/database/migrations/1771000000001_add_update_fields_to_services.ts",
    "content": "import { BaseSchema } from '@adonisjs/lucid/schema'\n\nexport default class extends BaseSchema {\n  protected tableName = 'services'\n\n  async up() {\n    this.schema.alterTable(this.tableName, (table) => {\n      table.string('source_repo', 255).nullable()\n      table.string('available_update_version', 50).nullable()\n      table.timestamp('update_checked_at').nullable()\n    })\n  }\n\n  async down() {\n    this.schema.alterTable(this.tableName, (table) => {\n      table.dropColumn('source_repo')\n      table.dropColumn('available_update_version')\n      table.dropColumn('update_checked_at')\n    })\n  }\n}\n"
  },
  {
    "path": "admin/database/migrations/1771000000002_pin_latest_service_images.ts",
    "content": "import { BaseSchema } from '@adonisjs/lucid/schema'\n\nexport default class extends BaseSchema {\n  protected tableName = 'services'\n\n  async up() {\n    this.defer(async (db) => {\n      // Pin :latest images to specific versions\n      await db\n        .from(this.tableName)\n        .where('container_image', 'ghcr.io/gchq/cyberchef:latest')\n        .update({ container_image: 'ghcr.io/gchq/cyberchef:10.19.4' })\n\n      await db\n        .from(this.tableName)\n        .where('container_image', 'dullage/flatnotes:latest')\n        .update({ container_image: 'dullage/flatnotes:v5.5.4' })\n\n      await db\n        .from(this.tableName)\n        .where('container_image', 'treehouses/kolibri:latest')\n        .update({ container_image: 'treehouses/kolibri:0.12.8' })\n\n      // Populate source_repo for services whose images lack the OCI source label\n      const sourceRepos: Record<string, string> = {\n        nomad_kiwix_server: 'https://github.com/kiwix/kiwix-tools',\n        nomad_ollama: 'https://github.com/ollama/ollama',\n        nomad_qdrant: 'https://github.com/qdrant/qdrant',\n        nomad_cyberchef: 'https://github.com/gchq/CyberChef',\n        nomad_flatnotes: 'https://github.com/dullage/flatnotes',\n        nomad_kolibri: 'https://github.com/learningequality/kolibri',\n      }\n\n      for (const [serviceName, repoUrl] of Object.entries(sourceRepos)) {\n        await db\n          .from(this.tableName)\n          .where('service_name', serviceName)\n          .update({ source_repo: repoUrl })\n      }\n    })\n  }\n\n  async down() {\n    this.defer(async (db) => {\n      await db\n        .from(this.tableName)\n        .where('container_image', 'ghcr.io/gchq/cyberchef:10.19.4')\n        .update({ container_image: 'ghcr.io/gchq/cyberchef:latest' })\n\n      await db\n        .from(this.tableName)\n        .where('container_image', 'dullage/flatnotes:v5.5.4')\n        .update({ container_image: 'dullage/flatnotes:latest' })\n\n      await db\n        .from(this.tableName)\n        .where('container_image', 'treehouses/kolibri:0.12.8')\n        .update({ container_image: 'treehouses/kolibri:latest' })\n    })\n  }\n}\n"
  },
  {
    "path": "admin/database/seeders/service_seeder.ts",
    "content": "import Service from '#models/service'\nimport { BaseSeeder } from '@adonisjs/lucid/seeders'\nimport { ModelAttributes } from '@adonisjs/lucid/types/model'\nimport env from '#start/env'\nimport { SERVICE_NAMES } from '../../constants/service_names.js'\n\nexport default class ServiceSeeder extends BaseSeeder {\n  // Use environment variable with fallback to production default\n  private static NOMAD_STORAGE_ABS_PATH = env.get(\n    'NOMAD_STORAGE_PATH',\n    '/opt/project-nomad/storage'\n  )\n  private static DEFAULT_SERVICES: Omit<\n    ModelAttributes<Service>,\n    'created_at' | 'updated_at' | 'metadata' | 'id' | 'available_update_version' | 'update_checked_at'\n  >[] = [\n    {\n      service_name: SERVICE_NAMES.KIWIX,\n      friendly_name: 'Information Library',\n      powered_by: 'Kiwix',\n      display_order: 1,\n      description:\n        'Offline access to Wikipedia, medical references, how-to guides, and encyclopedias',\n      icon: 'IconBooks',\n      container_image: 'ghcr.io/kiwix/kiwix-serve:3.8.1',\n      source_repo: 'https://github.com/kiwix/kiwix-tools',\n      container_command: '*.zim --address=all',\n      container_config: JSON.stringify({\n        HostConfig: {\n          RestartPolicy: { Name: 'unless-stopped' },\n          Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/zim:/data`],\n          PortBindings: { '8080/tcp': [{ HostPort: '8090' }] },\n        },\n        ExposedPorts: { '8080/tcp': {} },\n      }),\n      ui_location: '8090',\n      installed: false,\n      installation_status: 'idle',\n      is_dependency_service: false,\n      depends_on: null,\n    },\n    {\n      service_name: SERVICE_NAMES.QDRANT,\n      friendly_name: 'Qdrant Vector Database',\n      powered_by: null,\n      display_order: 100, // Dependency service, not shown directly\n      description: 'Vector database for storing and searching embeddings',\n      icon: 'IconRobot',\n      container_image: 'qdrant/qdrant:v1.16',\n      source_repo: 'https://github.com/qdrant/qdrant',\n      container_command: null,\n      container_config: JSON.stringify({\n        HostConfig: {\n          RestartPolicy: { Name: 'unless-stopped' },\n          Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/qdrant:/qdrant/storage`],\n          PortBindings: { '6333/tcp': [{ HostPort: '6333' }], '6334/tcp': [{ HostPort: '6334' }] },\n        },\n        ExposedPorts: { '6333/tcp': {}, '6334/tcp': {} },\n      }),\n      ui_location: '6333',\n      installed: false,\n      installation_status: 'idle',\n      is_dependency_service: true,\n      depends_on: null,\n    },\n    {\n      service_name: SERVICE_NAMES.OLLAMA,\n      friendly_name: 'AI Assistant',\n      powered_by: 'Ollama',\n      display_order: 3,\n      description: 'Local AI chat that runs entirely on your hardware - no internet required',\n      icon: 'IconWand',\n      container_image: 'ollama/ollama:0.15.2',\n      source_repo: 'https://github.com/ollama/ollama',\n      container_command: 'serve',\n      container_config: JSON.stringify({\n        HostConfig: {\n          RestartPolicy: { Name: 'unless-stopped' },\n          Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/ollama:/root/.ollama`],\n          PortBindings: { '11434/tcp': [{ HostPort: '11434' }] },\n        },\n        ExposedPorts: { '11434/tcp': {} },\n      }),\n      ui_location: '/chat',\n      installed: false,\n      installation_status: 'idle',\n      is_dependency_service: false,\n      depends_on: SERVICE_NAMES.QDRANT,\n    },\n    {\n      service_name: SERVICE_NAMES.CYBERCHEF,\n      friendly_name: 'Data Tools',\n      powered_by: 'CyberChef',\n      display_order: 11,\n      description: 'Swiss Army knife for data encoding, encryption, and analysis',\n      icon: 'IconChefHat',\n      container_image: 'ghcr.io/gchq/cyberchef:10.19.4',\n      source_repo: 'https://github.com/gchq/CyberChef',\n      container_command: null,\n      container_config: JSON.stringify({\n        HostConfig: {\n          RestartPolicy: { Name: 'unless-stopped' },\n          PortBindings: { '80/tcp': [{ HostPort: '8100' }] },\n        },\n        ExposedPorts: { '80/tcp': {} },\n      }),\n      ui_location: '8100',\n      installed: false,\n      installation_status: 'idle',\n      is_dependency_service: false,\n      depends_on: null,\n    },\n    {\n      service_name: SERVICE_NAMES.FLATNOTES,\n      friendly_name: 'Notes',\n      powered_by: 'FlatNotes',\n      display_order: 10,\n      description: 'Simple note-taking app with local storage',\n      icon: 'IconNotes',\n      container_image: 'dullage/flatnotes:v5.5.4',\n      source_repo: 'https://github.com/dullage/flatnotes',\n      container_command: null,\n      container_config: JSON.stringify({\n        HostConfig: {\n          RestartPolicy: { Name: 'unless-stopped' },\n          PortBindings: { '8080/tcp': [{ HostPort: '8200' }] },\n          Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/flatnotes:/data`],\n        },\n        ExposedPorts: { '8080/tcp': {} },\n        Env: ['FLATNOTES_AUTH_TYPE=none'],\n      }),\n      ui_location: '8200',\n      installed: false,\n      installation_status: 'idle',\n      is_dependency_service: false,\n      depends_on: null,\n    },\n    {\n      service_name: SERVICE_NAMES.KOLIBRI,\n      friendly_name: 'Education Platform',\n      powered_by: 'Kolibri',\n      display_order: 2,\n      description: 'Interactive learning platform with video courses and exercises',\n      icon: 'IconSchool',\n      container_image: 'treehouses/kolibri:0.12.8',\n      source_repo: 'https://github.com/learningequality/kolibri',\n      container_command: null,\n      container_config: JSON.stringify({\n        HostConfig: {\n          RestartPolicy: { Name: 'unless-stopped' },\n          PortBindings: { '8080/tcp': [{ HostPort: '8300' }] },\n          Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/kolibri:/root/.kolibri`],\n        },\n        ExposedPorts: { '8080/tcp': {} },\n      }),\n      ui_location: '8300',\n      installed: false,\n      installation_status: 'idle',\n      is_dependency_service: false,\n      depends_on: null,\n    },\n  ]\n\n  async run() {\n    const existingServices = await Service.query().select('service_name')\n    const existingServiceNames = new Set(existingServices.map((service) => service.service_name))\n\n    const newServices = ServiceSeeder.DEFAULT_SERVICES.filter(\n      (service) => !existingServiceNames.has(service.service_name)\n    )\n\n    await Service.createMany([...newServices])\n  }\n}\n"
  },
  {
    "path": "admin/docs/about.md",
    "content": "# About Project N.O.M.A.D.\n\nProject N.O.M.A.D. (Node for Offline Media, Archives, and Data; \"Nomad\" for short) is a project started in 2025 by Chris Sherwood of [Crosstalk Solutions, LLC](https://crosstalksolutions.com). The goal of the project is not to create just another utility for storing offline resources, but rather to allow users to run their own ultimate \"survival computer\".\n\nWhile many similar offline survival computers are designed to be run on bare-minimum, lightweight hardware, Project N.O.M.A.D. is quite the opposite. To install and run the available AI tools, we highly encourage the use of a beefy, GPU-backed device to make the most of your install. See the [Hardware Guide](https://www.projectnomad.us/hardware) for detailed build recommendations at three price points.\n\nSince its initial release, NOMAD has grown to include built-in AI chat with a Knowledge Base for document-aware responses, a System Benchmark with a community leaderboard, curated content collections with tiered options, and an Easy Setup Wizard to get new users up and running quickly.\n\nProject N.O.M.A.D. is open source, released under the [Apache License 2.0](https://github.com/Crosstalk-Solutions/project-nomad/blob/main/LICENSE).\n\n## Links\n\n- **Website:** [www.projectnomad.us](https://www.projectnomad.us)\n- **Hardware Guide:** [www.projectnomad.us/hardware](https://www.projectnomad.us/hardware)\n- **Discord:** [Join the Community](https://discord.com/invite/crosstalksolutions)\n- **GitHub:** [Crosstalk-Solutions/project-nomad](https://github.com/Crosstalk-Solutions/project-nomad)\n- **Benchmark Leaderboard:** [benchmark.projectnomad.us](https://benchmark.projectnomad.us)\n"
  },
  {
    "path": "admin/docs/faq.md",
    "content": "# Frequently Asked Questions\n\n## General Questions\n\n### What is N.O.M.A.D.?\nN.O.M.A.D. (Node for Offline Media, Archives, and Data) is a personal server that gives you access to knowledge, education, and AI assistance without requiring an internet connection. It runs on your own hardware, keeping your data private and accessible anytime.\n\n### Do I need internet to use N.O.M.A.D.?\nNo — that's the whole point. Once your content is downloaded, everything works offline. You only need internet to:\n- Download new content\n- Update the software\n- Sync the latest versions of Wikipedia, maps, etc.\n\n### What hardware do I need?\nN.O.M.A.D. is designed for capable hardware, especially if you want to use the AI features. Recommended:\n- Modern multi-core CPU (AMD Ryzen 7 with Radeon graphics is the community sweet spot)\n- 16GB+ RAM (32GB+ for best AI performance)\n- SSD storage (size depends on content — 500GB minimum, 1TB+ recommended)\n- NVIDIA or AMD GPU recommended for faster AI responses\n\n**For detailed build recommendations at three price points ($150–$1,000+), see the [Hardware Guide](https://www.projectnomad.us/hardware).**\n\n### How much storage do I need?\nIt depends on what you download:\n- Full Wikipedia: ~95GB\n- Khan Academy courses: ~50GB\n- Medical references: ~500MB\n- US state maps: ~2-3GB each\n- AI models: 10-40GB depending on model\n\nStart with essentials and add more as needed.\n\n---\n\n## Content Questions\n\n### How do I add more Wikipedia content?\n1. Go to **Settings** (hamburger menu → Settings)\n2. Click **Content Explorer**\n3. Browse available Wikipedia packages\n4. Click Download on items you want\n\nYou can also use the **Content Explorer** to browse all available ZIM content beyond Wikipedia.\n\n### How do I add more educational courses?\n1. Open **Kolibri**\n2. Sign in as an admin\n3. Go to **Device → Channels**\n4. Browse and import available channels\n\n### How current is the content?\nContent is as current as when it was last downloaded. Wikipedia snapshots are typically updated monthly. Check the file names or descriptions for dates.\n\n### Can I add my own files?\nYes — with the Knowledge Base. Upload PDFs, text files, and other documents to the [Knowledge Base](/knowledge-base), and the AI can reference them when answering your questions. This uses semantic search to find relevant information from your uploaded files.\n\nFor Kiwix content, N.O.M.A.D. uses standard ZIM files. For educational content, Kolibri uses its own channel format.\n\n### What are curated collection tiers?\nWhen selecting content in the Easy Setup wizard or Content Explorer, collections are organized into three tiers:\n- **Essential** — Core content for the category (smallest download)\n- **Standard** — Essential plus additional useful content\n- **Comprehensive** — Everything available for the category (largest download)\n\nThis helps you balance content coverage against storage usage.\n\n---\n\n## AI Questions\n\n### How do I use the AI chat?\n1. Go to [AI Chat](/chat) from the Command Center\n2. Type your question or request\n3. The AI responds in conversational style\n\nThe AI must be installed first — enable it during Easy Setup or install it from the [Apps](/settings/apps) page.\n\n### How do I upload documents to the Knowledge Base?\n1. Go to **[Knowledge Base →](/knowledge-base)**\n2. Upload your documents (PDFs, text files, etc.)\n3. Documents are processed and indexed automatically\n4. Ask questions in AI Chat — the AI will reference your uploaded documents when relevant\n\nYou can also remove documents from the Knowledge Base when they're no longer needed.\n\nNOMAD documentation is automatically added to the Knowledge Base when the AI Assistant is installed.\n\n### What is the System Benchmark?\nThe System Benchmark tests your hardware performance and generates a NOMAD Score — a weighted composite of CPU, memory, disk, and AI performance. You can create a Builder Tag (a NOMAD-themed identity like \"Tactical-Llama-1234\") and share your results with the [community leaderboard](https://benchmark.projectnomad.us).\n\nGo to **[System Benchmark →](/settings/benchmark)** to run one.\n\n### What is the Early Access Channel?\nThe Early Access Channel lets you opt in to receive release candidate builds with the latest features and improvements before they hit stable releases. You can enable or disable it from **Settings → Check for Updates**. Early access builds may contain bugs — if you prefer stability, stay on the stable channel.\n\n---\n\n## Troubleshooting\n\n### A feature isn't loading or shows a blank page\n\n**Try these steps:**\n1. Wait 30 seconds — some features take time to start\n2. Refresh the page (Ctrl+R or Cmd+R)\n3. Go back to the Command Center and try again\n4. Check Settings → System to see if the service is running\n5. Try restarting the service (Stop, then Start in Apps manager)\n\n### Maps show a gray/blank area\n\nThe Maps feature requires downloaded map data. If you see a blank area:\n1. Go to **Settings → Maps Manager**\n2. Download map regions for your area\n3. Wait for downloads to complete\n4. Return to Maps and refresh\n\n### AI responses are slow\n\nLocal AI requires significant computing power. To improve speed:\n- **Add a GPU** — An NVIDIA GPU with the NVIDIA Container Toolkit can improve AI speed by 10-20x or more\n- Close other applications on the server\n- Ensure adequate cooling (overheating causes throttling)\n- Consider using a smaller/faster AI model if available\n\n### How do I enable GPU acceleration for AI?\n\nN.O.M.A.D. automatically detects NVIDIA GPUs when the NVIDIA Container Toolkit is installed on the host system. To set up GPU acceleration:\n\n1. **Install an NVIDIA GPU** in your server (if not already present)\n2. **Install the NVIDIA Container Toolkit** on the host — follow the [official installation guide](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html)\n3. **Reinstall the AI Assistant** — Go to [Apps](/settings/apps), find AI Assistant, and click **Force Reinstall**\n\nN.O.M.A.D. will detect the GPU during installation and configure the AI to use it automatically. You'll see \"NVIDIA container runtime detected\" in the installation progress.\n\n**Tip:** Run a [System Benchmark](/settings/benchmark) before and after to see the difference. GPU-accelerated systems typically see 100+ tokens per second vs 10-15 on CPU only.\n\n### I added/changed my GPU but AI is still slow\n\nWhen you add or swap a GPU, N.O.M.A.D. needs to reconfigure the AI container to use it:\n\n1. Make sure the **NVIDIA Container Toolkit** is installed on the host\n2. Go to **[Apps](/settings/apps)**\n3. Find the **AI Assistant** and click **Force Reinstall**\n\nForce Reinstall recreates the AI container with GPU support enabled. Without this step, the AI continues to run on CPU only.\n\n### I see a \"GPU passthrough not working\" warning\n\nN.O.M.A.D. checks whether your GPU is actually accessible inside the AI container. If a GPU is detected on the host but isn't working inside the container, you'll see a warning banner on the System Information and AI Settings pages. Click the **\"Fix: Reinstall AI Assistant\"** button to recreate the container with proper GPU access. This preserves your downloaded AI models.\n\n### AI Chat not available\n\nThe AI Chat page requires the AI Assistant to be installed first:\n1. Go to **[Apps](/settings/apps)**\n2. Install the **AI Assistant**\n3. Wait for the installation to complete\n4. The AI Chat will then be accessible from the home screen or [Chat](/chat)\n\n### Knowledge Base upload stuck\n\nIf a document upload appears stuck in the Knowledge Base:\n1. Check that the AI Assistant is running in **Settings → Apps**\n2. Large documents take time to process — wait a few minutes\n3. Try uploading a smaller document to verify the system is working\n4. Check **Settings → System** for any error messages\n\n### Benchmark won't submit to leaderboard\n\nTo share results with the community leaderboard:\n- You must run a **Full Benchmark** (not System Only or AI Only)\n- The benchmark must include AI results (AI Assistant must be installed and working)\n- Your score must be higher than any previous submission from the same hardware\n\nIf submission fails, check the error message for details.\n\n### \"Service unavailable\" or connection errors\n\nThe service might still be starting up. Wait 1-2 minutes and try again.\n\nIf the problem persists:\n1. Go to **Settings → Apps**\n2. Find the problematic service\n3. Click **Restart**\n4. Wait 30 seconds, then try again\n\n### Downloads are stuck or failing\n\n1. Check your internet connection\n2. Go to **Settings** and check available storage\n3. If storage is full, delete unused content\n4. Cancel the stuck download and try again\n\n### The server won't start\n\nIf you can't access the Command Center at all:\n1. Verify the server hardware is powered on\n2. Check network connectivity\n3. Try accessing directly via the server's IP address\n4. Check server logs if you have console access\n\n### I forgot my Kolibri password\n\nKolibri passwords are managed separately:\n1. If you're an admin, you can reset user passwords in Kolibri's user management\n2. If you forgot the admin password, you may need to reset it via command line (contact your administrator)\n\n---\n\n## Updates and Maintenance\n\n### How do I update N.O.M.A.D.?\n1. Go to **Settings → Check for Updates**\n2. If an update is available, click to install\n3. The system will download updates and restart automatically\n4. This typically takes 2-5 minutes\n\n### Should I update regularly?\nYes, while you have internet access. Updates include:\n- Bug fixes\n- New features\n- Security improvements\n- Performance enhancements\n\n### How do I update content (Wikipedia, etc.)?\nContent updates are separate from software updates:\n1. Go to **Settings → Content Manager** or **Content Explorer**\n2. Check for newer versions of your installed content\n3. Download updated versions as needed\n\nTip: New Wikipedia snapshots are released approximately monthly.\n\n### What happens if an update fails?\nThe system is designed to recover gracefully. If an update fails:\n1. The previous version should continue working\n2. Try the update again later\n3. Check Settings → System for error messages\n\n### Command-Line Maintenance\n\nFor advanced troubleshooting or when you can't access the web interface, N.O.M.A.D. includes helper scripts in `/opt/project-nomad`:\n\n**Start all services:**\n```bash\nsudo bash /opt/project-nomad/start_nomad.sh\n```\n\n**Stop all services:**\n```bash\nsudo bash /opt/project-nomad/stop_nomad.sh\n```\n\n**Update Command Center:**\n```bash\nsudo bash /opt/project-nomad/update_nomad.sh\n```\n*Note: This updates the Command Center only, not individual apps. Update apps through the web interface.*\n\n**Uninstall N.O.M.A.D.:**\n```bash\ncurl -fsSL https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/uninstall_nomad.sh -o uninstall_nomad.sh\nsudo bash uninstall_nomad.sh\n```\n*Warning: This cannot be undone. All data will be deleted.*\n\n---\n\n## Privacy and Security\n\n### Is my data private?\nYes. N.O.M.A.D. runs entirely on your hardware. Your searches, AI conversations, and usage data never leave your server.\n\n### Can others access my server?\nBy default, N.O.M.A.D. is accessible on your local network. Anyone on the same network can access it. For public networks, consider additional security measures.\n\n### Does the AI send data anywhere?\nNo. The AI runs completely locally. Your conversations are not sent to any external service. The AI chat is built into the Command Center — there's no separate service to configure.\n\n---\n\n## Getting More Help\n\n### The AI can help\nTry asking a question in [AI Chat](/chat). The local AI can answer questions about many topics, including technical troubleshooting. If you've uploaded NOMAD documentation to the Knowledge Base, it can also help with NOMAD-specific questions.\n\n### Check the documentation\nYou're in the docs now. Use the menu to find specific topics.\n\n### Join the community\nGet help from other NOMAD users on **[Discord](https://discord.com/invite/crosstalksolutions)**.\n\n### Release Notes\nSee what's changed in each version: **[Release Notes](/docs/release-notes)**\n"
  },
  {
    "path": "admin/docs/getting-started.md",
    "content": "# Getting Started with N.O.M.A.D.\n\nThis guide will help you get the most out of your N.O.M.A.D. server.\n\n---\n\n## Easy Setup Wizard\n\nIf this is your first time using N.O.M.A.D., the Easy Setup wizard will help you get everything configured.\n\n**[Launch Easy Setup →](/easy-setup)**\n\n![Easy Setup Wizard — Step 1: Choose your capabilities](/docs/easy-setup-step1.png)\n\nThe wizard walks you through four simple steps:\n1. **Capabilities** — Choose what to enable: Information Library, AI Assistant, Education Platform, Maps, Data Tools, and Notes\n2. **Maps** — Select geographic regions for offline maps\n3. **Content** — Choose curated content collections with Essential, Standard, or Comprehensive tiers\n\n![Content tiers — Essential, Standard, and Comprehensive](/docs/easy-setup-tiers.png)\n4. **Review** — Confirm your selections and start downloading\n\nDepending on what you selected, downloads may take a while. You can monitor progress in the Settings area, continue using features that are already installed, or leave your server running overnight for large downloads.\n\n---\n\n## Understanding Your Tools\n\n### Information Library — Offline Knowledge (Kiwix)\n\nThe Information Library stores compressed versions of websites and references that work without internet.\n\n**What's included:**\n- Full Wikipedia (millions of articles)\n- Medical references and first aid guides\n- How-to guides and survival information\n- Classic books from Project Gutenberg\n\n**How to use it:**\n1. Click **Information Library** from the Command Center home screen or [Apps](/settings/apps) page\n2. Choose a collection (like Wikipedia)\n3. Search or browse just like the regular website\n\n---\n\n### Education Platform — Offline Courses (Kolibri)\n\nThe Education Platform provides complete educational courses that work offline.\n\n**What's included:**\n- Khan Academy video courses\n- Math, science, reading, and more\n- Progress tracking for learners\n- Works for all ages\n\n**How to use it:**\n1. Click **Education Platform** from the Command Center home screen or [Apps](/settings/apps) page\n2. Sign in or create a learner account\n3. Browse courses and start learning\n\n**Tip:** Kolibri supports multiple users. Create accounts for each family member to track individual progress.\n\n---\n\n### AI Assistant — Built-in Chat\n\n![AI Chat interface](/docs/ai-chat.png)\n\nN.O.M.A.D. includes a built-in AI chat interface powered by Ollama. It runs entirely on your server — no internet needed, no data sent anywhere.\n\n**What can it do:**\n- Answer questions on any topic\n- Explain complex concepts simply\n- Help with writing and editing\n- Reference your uploaded documents via the Knowledge Base\n- Brainstorm ideas and assist with problem-solving\n\n**How to use it:**\n1. Click **AI Chat** from the Command Center or go to [Chat](/chat)\n2. Type your question or request\n3. The AI responds in conversational style\n\n**Tip:** Be specific in your questions. Instead of \"tell me about plants,\" try \"what vegetables grow well in shade?\"\n\n**Note:** The AI Assistant must be installed first. Enable it during Easy Setup or install it from the [Apps](/settings/apps) page.\n\n**GPU Acceleration:** If your server has an NVIDIA GPU with the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) installed, N.O.M.A.D. will automatically use it for AI — dramatically faster responses (10-20x improvement). If you add a GPU later, go to [Apps](/settings/apps) and **Force Reinstall** the AI Assistant to enable it.\n\n---\n\n### Knowledge Base — Document-Aware AI\n\n![Knowledge Base upload interface](/docs/knowledge-base.png)\n\nThe Knowledge Base lets you upload documents so the AI can reference them when answering your questions. It uses semantic search (RAG via Qdrant) to find relevant information from your uploaded files.\n\n**Supported file types:**\n- PDFs, text files, and other document formats\n- NOMAD documentation is automatically loaded when the AI Assistant is installed\n\n**How to use it:**\n1. Go to **[Knowledge Base →](/knowledge-base)**\n2. Upload your documents (PDFs, text files, etc.)\n3. Documents are processed and indexed automatically\n4. Ask questions in AI Chat — the AI will reference your uploaded documents when relevant\n5. Remove documents you no longer need — they'll be deleted from the index and local storage\n\n**Use cases:**\n- Upload emergency plans for quick reference during a crisis\n- Load technical manuals and SOPs for offline work sites\n- Add curriculum guides for homeschooling\n- Store research papers for academic work\n\n---\n\n### Maps — Offline Navigation\n\n![Offline maps viewer](/docs/maps.png)\n\nView maps without internet. Download the regions you need before going offline.\n\n**How to use it:**\n1. Click **Maps** from the Command Center\n2. Navigate by dragging and zooming\n3. Search for locations using the search bar\n\n**To add more map regions:**\n1. Go to **Settings → Maps Manager**\n2. Select the regions you need\n3. Click Download\n\n**Tip:** Download maps for areas you travel to frequently, plus neighboring regions just in case.\n\n**[Open Maps →](/maps)**\n\n---\n\n## Managing Your Server\n\n### Adding More Content\n\nAs your needs change, you can add more content anytime:\n\n- **More apps:** Settings → Apps\n- **More references:** Settings → Content Explorer or Content Manager\n- **More map regions:** Settings → Maps Manager\n- **More educational content:** Through Kolibri's built-in content browser\n\n### Wikipedia Selector\n\n![Content Explorer — browse and download Wikipedia packages and curated collections](/docs/content-explorer.png)\n\nN.O.M.A.D. includes a dedicated Wikipedia content management tool for browsing and downloading Wikipedia packages.\n\n**How to use it:**\n1. Go to **[Content Explorer →](/settings/zim/remote-explorer)**\n2. Browse available Wikipedia packages by language and size\n3. Select and download the packages you want\n\n**Note:** Selecting a different Wikipedia package replaces the previously downloaded version. Only one Wikipedia selection is active at a time.\n\n### System Benchmark\n\n![System Benchmark with NOMAD Score and Builder Tag](/docs/benchmark.png)\n\nTest your hardware performance and see how your NOMAD build stacks up against the community.\n\n**How to use it:**\n1. Go to **[System Benchmark →](/settings/benchmark)**\n2. Choose a benchmark type: Full, System Only, or AI Only\n3. View your NOMAD Score (a weighted composite of CPU, memory, disk, and AI performance)\n4. Create a Builder Tag (your NOMAD-themed identity, like \"Tactical-Llama-1234\")\n5. Share your results with the [community leaderboard](https://benchmark.projectnomad.us)\n\n**Note:** Only Full Benchmarks with AI data can be shared to the community leaderboard.\n\n### Keeping Things Updated\n\nWhile you have internet, periodically check for updates:\n\n1. Go to **Settings → Check for Updates**\n2. If updates are available, click to install\n3. Wait for the update to complete (your server will restart)\n\nContent updates (Wikipedia, maps, etc.) can be managed separately from software updates.\n\n**Early Access Channel:** Want the latest features before they hit stable? Enable the Early Access Channel from the Check for Updates page to receive release candidate builds. You can switch back to stable anytime.\n\n### Monitoring System Health\n\nCheck on your server anytime:\n\n1. Go to **Settings → System**\n2. View CPU, memory, and storage usage\n3. Check system uptime and status\n\n---\n\n## Tips for Best Results\n\n### Before Going Offline\n\n- **Update everything** — Run software and content updates\n- **Download what you need** — Maps, references, educational content\n- **Test it** — Make sure features work while you still have internet to troubleshoot\n\n### Storage Management\n\nYour server has limited storage. Prioritize:\n- Content you'll actually use\n- Critical references (medical, survival)\n- Maps for your region\n- Educational content matching your needs\n\nCheck storage usage in **Settings → System**.\n\n### Getting Help\n\n- **In-app docs:** You're reading them now\n- **AI assistant:** Ask a question in [AI Chat](/chat)\n- **Release notes:** See what's new in each version\n\n---\n\n## Next Steps\n\nYou're ready to use N.O.M.A.D. Here are some things to try:\n\n1. **Look something up** — Search for a topic in the Information Library\n2. **Learn something** — Start a Khan Academy course in the Education Platform\n3. **Ask a question** — Chat with the AI in [AI Chat](/chat)\n4. **Explore maps** — Find your neighborhood in the Maps viewer\n5. **Upload a document** — Add a PDF to the [Knowledge Base](/knowledge-base) and ask the AI about it\n\nEnjoy your offline knowledge server!\n"
  },
  {
    "path": "admin/docs/home.md",
    "content": "# Welcome to Project N.O.M.A.D.\n\nYour personal offline knowledge server is ready to use.\n\n## What is N.O.M.A.D.?\n\n**N.O.M.A.D.** stands for **Node for Offline Media, Archives, and Data**. It's your personal server for accessing knowledge, education, and AI assistance — even when you have no internet connection.\n\nThink of it as having Wikipedia, Khan Academy, an AI assistant, and offline maps all in one place, running on hardware you control.\n\n![Command Center Dashboard](/docs/dashboard.png)\n\n## What Can You Do?\n\n### Browse Offline Knowledge\nAccess millions of Wikipedia articles, medical references, how-to guides, and ebooks — all stored locally on your server. No internet required.\n\n*Launch the Information Library from the home screen or the [Apps](/settings/apps) page.*\n\n### Learn Something New\nKhan Academy courses covering math, science, economics, and more. Complete with videos and exercises, all available offline.\n\n*Launch the Education Platform from the home screen or the [Apps](/settings/apps) page.*\n\n### Chat with AI\nAsk questions, get explanations, brainstorm ideas, or get help with writing. Your local AI assistant works completely offline — and you can upload documents to the Knowledge Base for document-aware responses.\n\n**[Open AI Chat →](/chat)**\n\n### Upload Documents to the Knowledge Base\nUpload PDFs, text files, and other documents for the AI to reference. The Knowledge Base uses semantic search to find relevant information from your uploaded documents when you ask questions.\n\n**[Open Knowledge Base →](/knowledge-base)**\n\n### View Offline Maps\nNavigate and explore maps without an internet connection. Download regions you need before going offline.\n\n**[Open Maps →](/maps)**\n\n### Benchmark Your Hardware\nRun a System Benchmark to see how your hardware performs and compare your NOMAD Score with the community leaderboard.\n\n**[Open Benchmark →](/settings/benchmark)**\n\n---\n\n## Getting Started\n\n**New to N.O.M.A.D.?** Use the Easy Setup wizard to configure your server and download content collections.\n\n**[Run Easy Setup →](/easy-setup)**\n\nOr explore the **[Getting Started Guide](/docs/getting-started)** for a walkthrough of all features.\n\n---\n\n## Quick Links\n\n| I want to... | Go here |\n|--------------|---------|\n| Chat with the AI | [AI Chat →](/chat) |\n| Upload documents for AI | [Knowledge Base →](/knowledge-base) |\n| Download more content | [Install Apps →](/settings/apps) |\n| Add Wikipedia/reference content | [Content Explorer →](/settings/zim/remote-explorer) |\n| Manage installed content | [Content Manager →](/settings/zim) |\n| Download map regions | [Maps Manager →](/settings/maps) |\n| Run a benchmark | [System Benchmark →](/settings/benchmark) |\n| Check for updates | [System Update →](/settings/update) |\n| View system status | [System Info →](/settings/system) |\n\n---\n\n## Keeping Your Server Updated\n\nN.O.M.A.D. works best when kept up to date while you have internet access. This ensures you have the latest:\n- Software features and bug fixes\n- Wikipedia and reference content\n- Educational materials\n- AI model improvements\n\nWhen you go offline, you'll have everything you need — the last synced versions of all your content.\n\n**[Check for Updates →](/settings/update)**\n"
  },
  {
    "path": "admin/docs/release-notes.md",
    "content": "# Release Notes\n\n## Version 1.30.0 - March 20, 2026\n\n### Features\n- **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!\n- **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!\n- **Support the Project**: Added a new \"Support the Project\" page in settings with links to community resources, donation options, and ways to contribute.\n- **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.\n\n### Bug Fixes\n- **Settings**: Storage usage display now prefers real block devices over tempfs. Thanks @Bortlesboat for the fix!\n- **Settings**: Fixed an issue where device matching and mount entry deduplication logic could cause incorrect storage usage reporting and missing devices in storage displays.\n- **Maps**: The Maps page now respects the request protocol (http vs https) to ensure map tiles load correctly. Thanks @davidgross for the bug report!\n- **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!\n- **Curated Collections**: Fixed some broken links in the curated collections definitions (maps and ZIM files) that were causing some resources to fail to download.\n- **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!\n- **UI**: Fixed an issue where the loading spinner could look strange in certain use cases.\n- **System Updates**: Fixed an issue where the update banner would persist even after the system was updated successfully. Thanks @chriscrosstalk for the fix!\n- **Performance**: Various small memory leak fixes and performance improvements across the UI to ensure a smoother experience.\n\n### Improvements\n- **Ollama**: Improved GPU detection logic to ensure the latest GPU config is always passed to the Ollama container on update\n- **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!\n- **Downloads**: Users can now dismiss failed download notifications to reduce clutter in the UI. Thanks @chriscrosstalk for the contribution!\n- **Logging**: Changed the default log level to \"info\" to reduce noise and focus on important messages. Thanks @traxeon for the suggestion!\n- **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.\n- **Dozzle**: Dozzle shell access and container actions are now disabled by default. Thanks @traxeon for the recommendation!\n- **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!\n- **Dependencies**: Various dependency updates to close security vulnerabilities and improve stability\n- **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.\n- **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.\n- **Documentation**: Updated installation instructions to reflect the new option for manual deployment via Docker Compose without the install script.\n\n\n## Version 1.29.0 - March 11, 2026\n\n### Features\n- **AI Assistant**: Added improved user guidance for troubleshooting GPU pass-through issues\n- **AI Assistant**: The last used model is now automatically selected when a new chat is started\n- **Settings**: Nomad now automatically performs nightly checks for available app updates, and users can select and apply updates from the Apps page in Settings\n\n### Bug Fixes\n- **Settings**: Fixed an issue where the AI Assistant settings page would be shown in navigation even if the AI Assistant was not installed, thus causing 404 errors when clicked\n- **Security**: Path traversal and SSRF mitigations\n- **AI Assistant**: Fixed an issue that was causing intermittent failures saving chat session titles\n\n### Improvements\n- **AI Assistant**: Extensive performance improvements and improved RAG intelligence/context usage\n\n## Version 1.28.0 - March 5, 2026\n\n### Features\n- **RAG**: Added support for viewing active embedding jobs in the processing queue and improved job progress tracking with more granular status updates\n- **RAG**: Added support for removing documents from the knowledge base (deletion from Qdrant and local storage)\n\n### Bug Fixes\n- **Install**: Fixed broken url's in install script and updated to prompt for Apache 2.0 license acceptance\n- **Docs**: Updated legal notices to reflect Apache 2.0 license and added Qdrant attribution\n- **Dependencies**: Various minor dependency updates to close security vulnerabilities\n\n### Improvements\n- **License**: Added Apache 2.0 license file to repository for clarity and legal compliance\n\n## Version 1.27.0 - March 4, 2026\n\n### Features\n- **Settings**: Added pagination support for Ollama model list\n- **Early Access Channel**: Allows users to opt in to receive early access builds with the latest features and improvements before they hit stable releases\n\n### Bug Fixes\n\n### Improvements\n- **AI Assistant**: Improved chat performance by optimizing query rewriting and response streaming logic\n- **CI/CD**: Updated release workflows to support release candidate versions\n- **KV Store**: Improved type safety in KV store implementation\n\n## Version 1.26.0 - February 19, 2026\n\n### Features\n- **AI Assistant**: Added support for showing reasoning stream for models with thinking capabilities\n- **AI Assistant**: Added support for response streaming for improved UX\n\n### Bug Fixes\n\n### Improvements\n\n\n## Version 1.25.2 - February 18, 2026\n\n### Features\n\n### Bug Fixes\n- **AI Assistant**: Fixed an error from chat suggestions when no Ollama models are installed\n- **AI Assistant**: Improved discrete GPU detection logic\n- **UI**: Legacy links to /docs and /knowledge-base now gracefully redirect to the correct pages instead of showing 404 errors\n\n### Improvements\n- **AI Assistant**: Chat suggestions are now disabled by default to avoid overwhelming smaller hardware setups\n\n## Version 1.25.1 - February 12, 2026\n\n### Features\n\n### Bug Fixes\n- **Settings**: Fix potential stale cache issue when checking for system updates\n- **Settings**: Improve user guidance during system updates\n\n### Improvements\n\n\n## Version 1.25.0 - February 12, 2026\n\n### Features\n- **Collections**: Complete overhaul of collection management with dynamic manifests, database tracking of installed resources, and improved UI for managing ZIM files and map assets\n- **Collections**: Added support for checking if newer versions of installed resources are available based on manifest data\n### Bug Fixes\n- **Benchmark**: Improved error handling and status code propagation for better user feedback on submission failures\n- **Benchmark**: Fix a race condition in the sysbench container management that could lead to benchmark test failures\n\n### Improvements\n\n---\n\n## Version 1.24.0 - February 10, 2026\n\n### 🚀 Features\n\n- **AI Assistant**: Query rewriting for enhanced context retrieval\n- **AI Assistant**: Allow manual scan and resync of Knowledge Base\n- **AI Assistant**: Integrated Knowledge Base UI into AI Assistant page\n- **AI Assistant**: ZIM content embedding into Knowledge Base\n- **Downloads**: Display model download progress\n- **System**: Cron job for automatic update checks\n- **Docs**: Polished documentation rendering with desert-themed components\n\n### 🐛 Bug Fixes\n\n- **AI Assistant**: Chat suggestion performance improvements\n- **AI Assistant**: Inline code rendering\n- **GPU**: Detect NVIDIA GPUs via Docker API instead of lspci\n- **Install**: Improve Docker GPU configuration\n- **System**: Correct memory usage percentage calculation\n- **System**: Show host OS, hostname, and GPU instead of container info\n- **Collections**: Correct devdocs ZIM filenames in Computing & Technology\n- **Downloads**: Sort active downloads by progress descending\n- **Docs**: Fix multiple broken internal links and route references\n\n### ✨ Improvements\n\n- **Docs**: Overhauled in-app documentation with sidebar ordering\n- **Docs**: Updated README with feature overview\n- **GPU**: Reusable utility for running nvidia-smi\n\n---\n\n## Version 1.23.0 - February 5, 2026\n\n### 🚀 Features\n\n- **Maps**: Maps now use full page by default\n- **Navigation**: Added \"Back to Home\" link on standard header pages\n- **AI**: Fuzzy search for AI models list\n- **UI**: Improved global error reporting with user notifications\n\n### 🐛 Bug Fixes\n\n- **Kiwix**: Avoid restarting the Kiwix container while download jobs are running\n- **Docker**: Ensure containers are fully removed on failed service install\n- **AI**: Filter cloud models from API response and fallback model list\n- **Curated Collections**: Prevent duplicate resources when fetching latest collections\n- **Content Tiers**: Rework tier system to dynamically determine install status on the server side\n\n### ✨ Improvements\n\n- **Docs**: Added pretty rendering for markdown tables in documentation pages\n\n---\n\n## Version 1.22.0 - February 4, 2026\n\n### 🚀 Features\n\n- **Content Manager**: Display friendly names (Title and Summary) instead of raw filenames for ZIM files\n- **AI Knowledge Base**: Automatically add NOMAD documentation to AI Knowledge Base on install\n\n### 🐛 Bug Fixes\n\n- **Maps**: Ensure map asset URLs resolve correctly when accessed via hostname\n- **Wikipedia**: Prevent loading spinner overlay during download\n- **Easy Setup**: Scroll to top when navigating between wizard steps\n- **AI Chat**: Hide chat button and page unless AI Assistant is actually installed\n- **Settings**: Rename confusing \"Port\" column to \"Location\" in Apps Settings\n\n### ✨ Improvements\n\n- **Ollama**: Cleanup model download logic and improve progress tracking\n\n---\n\n## Version 1.21.0 - February 2, 2026\n\n### 🚀 Features\n\n- **AI Assistant**: Built-in AI chat interface — no more separate Open WebUI app\n- **Knowledge Base**: Document upload with OCR, semantic search (RAG), and contextual AI responses via Qdrant\n- **Wikipedia Selector**: Dedicated Wikipedia content management with smart package selection\n- **GPU Support**: NVIDIA and AMD GPU passthrough for Ollama (faster AI inference)\n\n### 🐛 Bug Fixes\n\n- **Benchmark**: Detect Intel Arc Graphics on Core Ultra processors\n- **Easy Setup**: Remove built-in System Benchmark from wizard (now in Settings)\n- **Icons**: Switch to Tabler Icons for consistency, remove unused icon libraries\n- **Docker**: Avoid re-pulling existing images during install\n\n### ✨ Improvements\n\n- **Ollama**: Fallback list of recommended models if api.projectnomad.us is down\n- **Ollama/Qdrant**: Docker images pinned to specific versions for stability\n- **README**: Added website and community links\n- Removed Open WebUI as a separate installable app (replaced by built-in AI Chat)\n\n---\n\n## Version 1.20.0 - January 28, 2026\n\n### 🚀 Features\n\n- **Collections**: Expanded curated categories with more content and improved tier selection modal UX\n- **Legal**: Expanded Legal Notices and moved to bottom of Settings sidebar\n\n### 🐛 Bug Fixes\n\n- **Install**: Handle missing curl dependency on fresh Ubuntu installs\n- **Migrations**: Fix timestamp ordering for builder_tag migration\n\n---\n\n## Version 1.19.0 - January 28, 2026\n\n### 🚀 Features\n\n- **Benchmark**: Builder Tag system — claim leaderboard spots with NOMAD-themed tags (e.g., \"Tactical-Llama-1234\")\n- **Benchmark**: Full benchmark with AI now required for community sharing; HMAC-signed submissions\n- **Release Notes**: Subscribe to release notes via email\n- **Maps**: Automatically download base map assets if missing\n\n### 🐛 Bug Fixes\n\n- **System Info**: Fall back to fsSize when disk array is empty (fixes \"No storage devices detected\")\n\n---\n\n## Version 1.18.0 - January 24, 2026\n\n### 🚀 Features\n\n- **Collections**: Improved curated collections UX with persistent tier selection and submit-to-confirm workflow\n\n### 🐛 Bug Fixes\n\n- **Benchmark**: Fix AI benchmark connectivity (Docker container couldn't reach Ollama on host)\n- **Open WebUI**: Fix install status indicator\n\n### ✨ Improvements\n\n- **Docker**: Container URL resolution utility and networking improvements\n\n---\n\n## Version 1.17.0 - January 23, 2026\n\n### 🚀 Features\n\n- **System Benchmark**: Hardware scoring with NOMAD Score, circular gauges, and community leaderboard submission\n- **Dashboard**: User-friendly app names with \"Powered by\" open source attribution\n- **Settings**: Updated nomenclature and added tiered content collections to Settings pages\n- **Queues**: Support working all queues with a single command\n\n### 🐛 Bug Fixes\n\n- **Easy Setup**: Select valid primary disk for storage projection bar\n- **Docs**: Remove broken service links that pointed to invalid routes\n- **Notifications**: Improved styling\n- **UI**: Remove splash screen\n- **Maps**: Static path resolution fix\n\n---\n\n## Version 1.16.0 - January 20, 2026\n\n### 🚀 Features\n\n- **Apps**: Force-reinstall option for installed applications\n- **Open WebUI**: Manage Ollama models directly from Command Center\n- **Easy Setup**: Show selected AI model size in storage projection bar\n\n### ✨ Improvements\n\n- **Curated Categories**: Improved fetching from GitHub\n- **Build**: Added dockerignore file\n\n---\n\n## Version 1.15.0 - January 19, 2026\n\n### 🚀 Features\n\n- **Easy Setup Wizard**: Redesigned Step 1 with user-friendly capability cards instead of app names\n- **Tiered Collections**: Category-based content collections with Essential, Standard, and Comprehensive tiers\n- **Storage Projection Bar**: Visual disk usage indicator showing projected additions during Easy Setup\n- **Windows Support**: Docker Desktop support for local development with platform detection and NOMAD_STORAGE_PATH env var\n- **Documentation**: Comprehensive in-app documentation (Home, Getting Started, FAQ, Use Cases)\n\n### ✨ Improvements\n\n- **Easy Setup**: Renamed step 3 label from \"ZIM Files\" to \"Content\"\n- **Notifications**: Fixed auto-dismiss not working due to stale closure\n- Added Survival & Preparedness and Education & Reference content categories\n\n---\n\n## Version 1.14.0 - January 16, 2026\n\n### 🚀 Features\n\n- **Collections**: Auto-fetch latest curated collections from GitHub\n\n### 🐛 Bug Fixes\n\n- **Docker**: Improved container state management\n\n---\n\n## Version 1.13.0 - January 15, 2026\n\n### 🚀 Features\n\n- **Easy Setup Wizard**: Initial implementation of the guided first-time setup experience\n- **Maps**: Enhanced missing assets warnings\n- **Apps**: Improved app cards with custom icons\n\n### 🐛 Bug Fixes\n\n- **Curated Collections**: UI tweaks\n- **Install**: Changed admin container pull_policy to always\n\n---\n\n## Version 1.12.0 - 1.12.3 - December 24, 2025 - January 13, 2026\n\n### 🚀 Features\n\n- **System**: Check internet status on backend with custom test URL support\n\n### 🐛 Bug Fixes\n\n- **Admin**: Improved service install status management\n- **Admin**: Improved duplicate install request handling\n- **Admin**: Fixed base map assets download URL\n- **Admin**: Fixed port binding for Open WebUI\n- **Admin**: Improved memory usage indicators\n- **Admin**: Added favicons\n- **Admin**: Fixed container healthcheck\n- **Admin**: Fixed missing ZIM download API client method\n- **Install**: Fixed disk info file mount and stability\n- **Install**: Ensure update script always pulls latest images\n- **Install**: Use modern docker compose command in update script\n- **Install**: Ensure update script is executable\n- **Scripts**: Remove disk info file on uninstall\n\n---\n\n## Version 1.11.0 - 1.11.1 - December 24, 2025\n\n### 🚀 Features\n\n- **Maps**: Curated map region collections\n- **Collections**: Map region collection definitions\n\n### 🐛 Bug Fixes\n\n- **Maps**: Fixed custom pmtiles file downloads\n- **Docs**: Documentation renderer fixes\n\n---\n\n## Version 1.10.1 - December 5, 2025\n\n### ✨ Improvements\n- **Kiwix**: ZIM storage path improvements\n\n---\n\n## Version 1.10.0 - December 5, 2025\n\n### 🚀 Features\n\n- Disk info monitoring\n\n### ✨ Improvements\n\n- **Install**: Add Redis env variables to compose file\n- **Kiwix**: Initial download and setup\n\n---\n\n## Version 1.9.0 - December 5, 2025\n\n### 🚀 Features\n\n- Background job management with BullMQ\n\n### ✨ Improvements\n\n- **Install**: Character escaping in env variables\n- **Install**: Host env variable\n\n---\n\n## Version 1.8.0 - December 5, 2025\n\n### 🚀 Features\n\n- Alert and button styles redesign\n- System info page redesign\n- **Collections**: Curated ZIM Collections with slug, icon, and language support\n- Custom map and ZIM file downloads (WIP)\n- New maps system (WIP)\n\n### ✨ Improvements\n\n- **DockerService**: Cleanup old OSM stuff\n- **Install**: Standardize compose file names\n\n---\n\n## Version 1.7.0 - December 5, 2025\n\n### 🚀 Features\n\n- Alert and button styles redesign\n- System info page redesign\n- **Collections**: Curated ZIM Collections\n- Custom map and ZIM file downloads (WIP)\n- New maps system (WIP)\n\n### ✨ Improvements\n\n- **DockerService**: Cleanup old OSM stuff\n- **Install**: Standardize compose file names\n\n---\n\n## Version 1.6.0 - November 18, 2025\n\n### 🚀 Features\n\n- Added Kolibri to standard app library\n\n### ✨ Improvements\n\n- Standardize container names in management-compose\n\n---\n\n## Version 1.5.0 - November 18, 2025\n\n### 🚀 Features\n\n- Version footer and fix CI version handling\n\n---\n\n## Version 1.4.0 - November 18, 2025\n\n### 🚀 Features\n\n- **Services**: Friendly names and descriptions\n\n### ✨ Improvements\n\n- **Scripts**: Logs directory creation improvements\n- **Scripts**: Fix typo in management-compose file path\n\n---\n\n## Version 1.3.0 - October 9, 2025\n\n### 🚀 New Features\n\n- Uninstall script now removes non-management Nomad app containers\n\n### ✨ Improvements\n\n- **OpenStreetMap**: Apply dir permission fixes more robustly\n\n---\n\n## Version 1.2.0 - October 7, 2025\n\n### 🚀 New Features\n\n- Added CyberChef to standard app library\n- Added Dozzle to core containers for enhanced logs and metrics\n- Added FlatNotes to standard app library\n- Uninstall helper script available\n\n### ✨ Improvements\n\n- **OpenStreetMap**:\n    - Fixed directory paths and access issues\n    - Improved error handling\n    - Fixed renderer file permissions\n    - Fixed absolute host path issue\n- **ZIM Manager**:\n    - Initial ZIM download now hosted in Project Nomad GitHub repo for better availability\n\n---\n\n## Version 1.1.0 - August 20, 2025\n\n### 🚀 New Features\n\n**OpenStreetMap Installation**\n- Added OpenStreetMap to installable applications\n- Automatically downloads and imports US Pacific region during installation.\n- Supports rendered tile caching for enhanced performance.\n\n### ✨ Improvements\n\n- **Apps**: Added start/stop/restart controls for each application container in settings\n- **ZIM Manager**: Error-handling/resumable downloads + enhanced UI\n- **System**: You can now view system information such as CPU, RAM, and disk stats in settings\n- **Legal**: Added legal notices in settings\n- **UI**: Added general UI enhancements such as alerts and error dialogs\n- Standardized container naming to reduce potential for conflicts with existing containers on host system\n\n### ⚠️ Breaking Changes\n\n- **Container Naming**: As a result of standardized container naming, it is recommend that you do a fresh install of Project N.O.M.A.D. and any apps to avoid potential conflicts/duplication of containers\n\n### 📚 Documentation\n\n- Added release notes page\n\n---\n\n## Version 1.0.1 - July 11, 2025\n\n### 🐛 Bug Fixes\n\n- **Docs**: Fixed doc rendering\n- **Install**: Fixed installation script URLs\n- **OpenWebUI**: Fixed Ollama connection\n\n---\n\n## Version 1.0.0 - July 11, 2025\n\n### 🚀 New Features\n\n- Initial alpha release for app installation and documentation\n- OpenWebUI, Ollama, Kiwix installation\n- ZIM downloads & management\n\n---\n\n## Support\n\n- **Discord:** [Join the Community](https://discord.com/invite/crosstalksolutions) — Get help, share your builds, and connect with other NOMAD users\n- **Bug Reports:** [GitHub Issues](https://github.com/Crosstalk-Solutions/project-nomad/issues)\n- **Website:** [www.projectnomad.us](https://www.projectnomad.us)\n\n---\n\n*For the full changelog, see our [GitHub releases](https://github.com/Crosstalk-Solutions/project-nomad/releases).*\n"
  },
  {
    "path": "admin/docs/use-cases.md",
    "content": "# What Can You Do With N.O.M.A.D.?\n\nN.O.M.A.D. is designed to be your information lifeline when internet isn't available. Here's how different people use it.\n\n---\n\n## Emergency Preparedness\n\nWhen disasters strike, internet and cell service often go down first. N.O.M.A.D. keeps critical information at your fingertips.\n\n**What you can do:**\n- Look up first aid and emergency medical procedures\n- Access survival guides and emergency protocols\n- Find information about water purification, food storage, shelter building\n- Use offline maps to navigate when GPS services are degraded\n- Research plant identification, weather patterns, radio frequencies\n- Upload emergency plans and protocols to the Knowledge Base for quick AI-assisted reference\n\n**Recommended content:**\n- Medical Library ZIM collection\n- Survival/Prepper reference guides\n- Maps for your region and evacuation routes\n- Wikipedia (searchable for almost any topic)\n\n---\n\n## Homeschooling and Education\n\nTeach your children anywhere, with or without internet. Complete curriculum available offline.\n\n**What you can do:**\n- Access Khan Academy's full course library (math, science, reading, history)\n- Track progress for multiple students\n- Supplement with Wikipedia for research projects\n- Use the AI as a patient tutor for any subject\n- Access classic literature through Project Gutenberg\n- Upload curriculum guides to the Knowledge Base so the AI can help answer curriculum-specific questions\n\n**Recommended content:**\n- Khan Academy courses via Kolibri\n- Wikipedia for Schools (curated for younger learners)\n- Project Gutenberg (classic books)\n- Educational ZIM collections\n\n**Tip:** Create separate Kolibri accounts for each child to track their individual progress.\n\n---\n\n## Off-Grid Living\n\nLiving away from reliable internet doesn't mean living without information.\n\n**What you can do:**\n- Research DIY projects and repairs\n- Look up gardening, animal husbandry, food preservation\n- Access medical references for remote healthcare\n- Learn new skills through educational videos\n- Get AI help with planning and problem-solving\n\n**Recommended content:**\n- How-to and DIY reference collections\n- Medical and first aid guides\n- Agricultural and homesteading references\n- Maps for your rural area\n- Practical skills courses in Kolibri\n\n---\n\n## Remote Work Sites\n\nConstruction sites, research stations, ships, and remote facilities often lack reliable internet.\n\n**What you can do:**\n- Access technical references and documentation\n- Use AI for writing assistance and analysis\n- Upload technical manuals and SOPs to the Knowledge Base for document-aware AI responses\n- Look up regulations, standards, and procedures\n- Provide educational resources for workers\n- Maintain communication records with note-taking apps\n\n**Recommended content:**\n- Industry-specific technical references\n- Relevant Wikipedia categories\n- Maps of work areas\n- Documentation and compliance guides\n\n---\n\n## Travel and Expeditions\n\nInternational travel, cruises, camping trips — stay informed anywhere.\n\n**What you can do:**\n- Access maps without expensive roaming data\n- Research destinations, history, and culture\n- Translate concepts with AI assistance\n- Identify plants, animals, and geological features\n- Access travel health information\n\n**Recommended content:**\n- Maps for destination countries/regions\n- Wikipedia in relevant languages\n- Medical/health references\n- Cultural and historical content\n\n---\n\n## Privacy-Conscious Users\n\nSome people simply prefer to keep their searches and questions private.\n\n**What you can do:**\n- Search Wikipedia without being tracked\n- Ask AI questions that stay on your own hardware\n- Upload sensitive documents to the Knowledge Base — they never leave your server\n- Learn about sensitive topics privately\n- Keep your intellectual curiosity to yourself\n\n**How it works:**\n- All data stays on your server\n- No search history sent to companies\n- No AI conversations leave your network — the AI chat is built into the Command Center\n- All Knowledge Base processing happens locally\n- You control your own information\n\n---\n\n## Medical Reference\n\nWhen you can't reach a doctor, having reliable medical information can be critical.\n\n**What you can access:**\n- NHS Medicines A-Z (drug information and interactions)\n- Medical Library (field medicine, emergency procedures)\n- First aid guides\n- Anatomy and physiology references\n- Disease and symptom information\n\n**Important:** Medical references are for information only. They don't replace professional medical care. In emergencies, always seek professional help when possible.\n\n**Recommended content:**\n- Medical Essentials ZIM collection\n- NHS Medicines reference\n- First aid and emergency medicine guides\n\n---\n\n## Academic Research\n\nStudents and researchers can work without depending on university networks.\n\n**What you can do:**\n- Access Wikipedia's extensive article database\n- Use AI for research assistance and summarization\n- Upload research papers to the Knowledge Base for AI-assisted analysis and cross-referencing\n- Work on papers and projects offline\n- Cross-reference multiple sources\n- Take notes with built-in tools\n\n**Recommended content:**\n- Full Wikipedia\n- Academic and educational references\n- Subject-specific ZIM collections\n- Note-taking apps (FlatNotes)\n\n---\n\n## Setting Up for Your Use Case\n\n### Step 1: Identify Your Needs\nWhat situations might you face without internet? What information would you need?\n\n### Step 2: Prioritize Content\nStorage is limited. Focus on:\n1. Critical safety information (medical, emergency)\n2. Content matching your primary use case\n3. General reference (Wikipedia)\n4. Nice-to-have additions\n\n### Step 3: Upload Relevant Documents\nAdd your own documents to the [Knowledge Base](/knowledge-base) — emergency plans, technical manuals, curriculum guides, or research papers. The AI can reference these when you ask questions.\n\n### Step 4: Download While You Can\nKeep your server updated while you have internet. You never know when you'll need to go offline.\n\n### Step 5: Practice\nTry using N.O.M.A.D. before you need it. Familiarity with the tools makes them more useful in a crisis.\n\n---\n\n## Need Something Specific?\n\nN.O.M.A.D. content is customizable. If you don't see what you need:\n\n1. **Browse [Content Explorer](/settings/zim/remote-explorer)** — Thousands of ZIM files including Wikipedia packages\n2. **Check [Content Manager](/settings/zim)** — Manage your installed content\n3. **Browse Kolibri channels** — Educational content for many subjects\n4. **Upload your own documents** — Add files to the [Knowledge Base](/knowledge-base) for AI-aware reference\n5. **Request features** — Let us know what content would help you on [Discord](https://discord.com/invite/crosstalksolutions)\n\nYour offline server, your content choices.\n"
  },
  {
    "path": "admin/eslint.config.js",
    "content": "import { configApp } from '@adonisjs/eslint-config'\nimport pluginQuery from '@tanstack/eslint-plugin-query'\nexport default configApp(...pluginQuery.configs['flat/recommended'])\n"
  },
  {
    "path": "admin/inertia/app/app.tsx",
    "content": "/// <reference path=\"../../adonisrc.ts\" />\n/// <reference path=\"../../config/inertia.ts\" />\n\nimport '../css/app.css'\nimport { createRoot } from 'react-dom/client'\nimport { createInertiaApp } from '@inertiajs/react'\nimport { resolvePageComponent } from '@adonisjs/inertia/helpers'\nimport ModalsProvider from '~/providers/ModalProvider'\nimport { TransmitProvider } from 'react-adonis-transmit'\nimport { generateUUID } from '~/lib/util'\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query'\nimport { ReactQueryDevtools } from '@tanstack/react-query-devtools'\nimport NotificationsProvider from '~/providers/NotificationProvider'\nimport { ThemeProvider } from '~/providers/ThemeProvider'\nimport { UsePageProps } from '../../types/system'\n\nconst appName = import.meta.env.VITE_APP_NAME || 'Project N.O.M.A.D.'\nconst queryClient = new QueryClient()\n\n// Patch the global crypto object for non-HTTPS/localhost contexts\nif (!window.crypto?.randomUUID) {\n  // @ts-ignore\n  if (!window.crypto) window.crypto = {}\n  // @ts-ignore\n  window.crypto.randomUUID = generateUUID\n}\n\ncreateInertiaApp({\n  progress: { color: '#424420' },\n\n  title: (title) => `${title} - ${appName}`,\n\n  resolve: (name) => {\n    return resolvePageComponent(`../pages/${name}.tsx`, import.meta.glob('../pages/**/*.tsx'))\n  },\n\n  setup({ el, App, props }) {\n    const environment = (props.initialPage.props as unknown as UsePageProps).environment\n    const showDevtools = ['development', 'staging'].includes(environment)\n    createRoot(el).render(\n      <QueryClientProvider client={queryClient}>\n        <ThemeProvider>\n          <TransmitProvider baseUrl={window.location.origin} enableLogging={environment === 'development'}>\n            <NotificationsProvider>\n              <ModalsProvider>\n                <App {...props} />\n                {showDevtools && <ReactQueryDevtools initialIsOpen={false} buttonPosition='bottom-left' />}\n              </ModalsProvider>\n            </NotificationsProvider>\n          </TransmitProvider>\n        </ThemeProvider>\n      </QueryClientProvider>\n    )\n  },\n})\n"
  },
  {
    "path": "admin/inertia/components/ActiveDownloads.tsx",
    "content": "import useDownloads, { useDownloadsProps } from '~/hooks/useDownloads'\nimport HorizontalBarChart from './HorizontalBarChart'\nimport { extractFileName } from '~/lib/util'\nimport StyledSectionHeader from './StyledSectionHeader'\nimport { IconAlertTriangle, IconX } from '@tabler/icons-react'\nimport api from '~/lib/api'\n\ninterface ActiveDownloadProps {\n  filetype?: useDownloadsProps['filetype']\n  withHeader?: boolean\n}\n\nconst ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps) => {\n  const { data: downloads, invalidate } = useDownloads({ filetype })\n\n  const handleDismiss = async (jobId: string) => {\n    await api.removeDownloadJob(jobId)\n    invalidate()\n  }\n\n  return (\n    <>\n      {withHeader && <StyledSectionHeader title=\"Active Downloads\" className=\"mt-12 mb-4\" />}\n      <div className=\"space-y-4\">\n        {downloads && downloads.length > 0 ? (\n          downloads.map((download) => (\n            <div\n              key={download.jobId}\n              className={`bg-desert-white rounded-lg p-4 border shadow-sm hover:shadow-lg transition-shadow ${\n                download.status === 'failed'\n                  ? 'border-red-300'\n                  : 'border-desert-stone-light'\n              }`}\n            >\n              {download.status === 'failed' ? (\n                <div className=\"flex items-center gap-2\">\n                  <IconAlertTriangle className=\"w-5 h-5 text-red-500 flex-shrink-0\" />\n                  <div className=\"flex-1 min-w-0\">\n                    <p className=\"text-sm font-medium text-gray-900 truncate\">\n                      {extractFileName(download.filepath) || download.url}\n                    </p>\n                    <p className=\"text-xs text-red-600 mt-0.5\">\n                      Download failed{download.failedReason ? `: ${download.failedReason}` : ''}\n                    </p>\n                  </div>\n                  <button\n                    onClick={() => handleDismiss(download.jobId)}\n                    className=\"flex-shrink-0 p-1 rounded hover:bg-red-100 transition-colors\"\n                    title=\"Dismiss failed download\"\n                  >\n                    <IconX className=\"w-4 h-4 text-red-400 hover:text-red-600\" />\n                  </button>\n                </div>\n              ) : (\n                <HorizontalBarChart\n                  items={[\n                    {\n                      label: extractFileName(download.filepath) || download.url,\n                      value: download.progress,\n                      total: '100%',\n                      used: `${download.progress}%`,\n                      type: download.filetype,\n                    },\n                  ]}\n                />\n              )}\n            </div>\n          ))\n        ) : (\n          <p className=\"text-text-muted\">No active downloads</p>\n        )}\n      </div>\n    </>\n  )\n}\n\nexport default ActiveDownloads\n"
  },
  {
    "path": "admin/inertia/components/ActiveEmbedJobs.tsx",
    "content": "import useEmbedJobs from '~/hooks/useEmbedJobs'\nimport HorizontalBarChart from './HorizontalBarChart'\nimport StyledSectionHeader from './StyledSectionHeader'\n\ninterface ActiveEmbedJobsProps {\n  withHeader?: boolean\n}\n\nconst ActiveEmbedJobs = ({ withHeader = false }: ActiveEmbedJobsProps) => {\n  const { data: jobs } = useEmbedJobs()\n\n  return (\n    <>\n      {withHeader && (\n        <StyledSectionHeader title=\"Processing Queue\" className=\"mt-12 mb-4\" />\n      )}\n      <div className=\"space-y-4\">\n        {jobs && jobs.length > 0 ? (\n          jobs.map((job) => (\n            <div\n              key={job.jobId}\n              className=\"bg-desert-white rounded-lg p-4 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow\"\n            >\n              <HorizontalBarChart\n                items={[\n                  {\n                    label: job.fileName,\n                    value: job.progress,\n                    total: '100%',\n                    used: `${job.progress}%`,\n                    type: job.status,\n                  },\n                ]}\n              />\n            </div>\n          ))\n        ) : (\n          <p className=\"text-text-muted\">No files are currently being processed</p>\n        )}\n      </div>\n    </>\n  )\n}\n\nexport default ActiveEmbedJobs\n"
  },
  {
    "path": "admin/inertia/components/ActiveModelDownloads.tsx",
    "content": "import useOllamaModelDownloads from '~/hooks/useOllamaModelDownloads'\nimport HorizontalBarChart from './HorizontalBarChart'\nimport StyledSectionHeader from './StyledSectionHeader'\n\ninterface ActiveModelDownloadsProps {\n    withHeader?: boolean\n}\n\nconst ActiveModelDownloads = ({ withHeader = false }: ActiveModelDownloadsProps) => {\n    const { downloads } = useOllamaModelDownloads()\n\n    return (\n        <>\n            {withHeader && <StyledSectionHeader title=\"Active Model Downloads\" className=\"mt-12 mb-4\" />}\n            <div className=\"space-y-4\">\n                {downloads && downloads.length > 0 ? (\n                    downloads.map((download) => (\n                        <div\n                            key={download.model}\n                            className=\"bg-desert-white rounded-lg p-4 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow\"\n                        >\n                            <HorizontalBarChart\n                                items={[\n                                    {\n                                        label: download.model,\n                                        value: download.percent,\n                                        total: '100%',\n                                        used: `${download.percent.toFixed(1)}%`,\n                                        type: 'ollama-model',\n                                    },\n                                ]}\n                            />\n                        </div>\n                    ))\n                ) : (\n                    <p className=\"text-text-muted\">No active model downloads</p>\n                )}\n            </div>\n        </>\n    )\n}\n\nexport default ActiveModelDownloads\n"
  },
  {
    "path": "admin/inertia/components/Alert.tsx",
    "content": "import * as Icons from '@tabler/icons-react'\nimport classNames from '~/lib/classNames'\nimport DynamicIcon from './DynamicIcon'\nimport StyledButton, { StyledButtonProps } from './StyledButton'\n\nexport type AlertProps = React.HTMLAttributes<HTMLDivElement> & {\n  title: string\n  message?: string\n  type: 'warning' | 'error' | 'success' | 'info' | 'info-inverted'\n  children?: React.ReactNode\n  dismissible?: boolean\n  onDismiss?: () => void\n  icon?: keyof typeof Icons\n  variant?: 'standard' | 'bordered' | 'solid'\n  buttonProps?: StyledButtonProps\n}\n\nexport default function Alert({\n  title,\n  message,\n  type,\n  children,\n  dismissible = false,\n  onDismiss,\n  icon,\n  variant = 'standard',\n  buttonProps,\n  ...props\n}: AlertProps) {\n  const getDefaultIcon = (): keyof typeof Icons => {\n    switch (type) {\n      case 'warning':\n        return 'IconAlertTriangle'\n      case 'error':\n        return 'IconXboxX'\n      case 'success':\n        return 'IconCircleCheck'\n      case 'info':\n        return 'IconInfoCircle'\n      default:\n        return 'IconInfoCircle'\n    }\n  }\n\n  const getIconColor = () => {\n    if (variant === 'solid') return 'text-white'\n    switch (type) {\n      case 'warning':\n        return 'text-desert-orange'\n      case 'error':\n        return 'text-desert-red'\n      case 'success':\n        return 'text-desert-olive'\n      case 'info':\n        return 'text-desert-stone'\n      default:\n        return 'text-desert-stone'\n    }\n  }\n\n  const getVariantStyles = () => {\n    const baseStyles = 'rounded-lg transition-all duration-200'\n    const variantStyles: string[] = []\n\n    switch (variant) {\n      case 'bordered':\n        variantStyles.push(\n          type === 'warning'\n            ? 'border-desert-orange'\n            : type === 'error'\n              ? 'border-desert-red'\n              : type === 'success'\n                ? 'border-desert-olive'\n                : type === 'info'\n                  ? 'border-desert-stone'\n                  : type === 'info-inverted'\n                    ? 'border-desert-tan'\n                  : ''\n        )\n        return classNames(baseStyles, 'border-2 bg-desert-white shadow-md', ...variantStyles)\n      case 'solid':\n        variantStyles.push(\n          type === 'warning'\n            ? 'bg-desert-orange text-white border border-desert-orange-dark'\n            : type === 'error'\n              ? 'bg-desert-red text-white border border-desert-red-dark'\n              : type === 'success'\n                ? 'bg-desert-olive text-white border border-desert-olive-dark'\n                : type === 'info'\n                  ? 'bg-desert-green text-white border border-desert-green-dark'\n                  : type === 'info-inverted'\n                    ? 'bg-desert-tan text-white border border-desert-tan-dark'\n                  : ''\n        )\n        return classNames(baseStyles, 'shadow-lg', ...variantStyles)\n      default:\n        variantStyles.push(\n          type === 'warning'\n            ? 'bg-desert-orange-lighter bg-opacity-20 border-desert-orange-light'\n            : type === 'error'\n              ? 'bg-desert-red-lighter bg-opacity-20 border-desert-red-light'\n              : type === 'success'\n                ? 'bg-desert-olive-lighter bg-opacity-20 border-desert-olive-light'\n                : type === 'info'\n                  ? 'bg-desert-green bg-opacity-20 border-desert-green-light'\n                  : type === 'info-inverted'\n                  ? 'bg-desert-tan bg-opacity-20 border-desert-tan-light'\n                  : ''\n        )\n        return classNames(baseStyles, 'border-l-4 border-y border-r shadow-sm', ...variantStyles)\n    }\n  }\n\n  const getTitleColor = () => {\n    if (variant === 'solid') return 'text-white'\n\n    switch (type) {\n      case 'warning':\n        return 'text-desert-orange-dark'\n      case 'error':\n        return 'text-desert-red-dark'\n      case 'success':\n        return 'text-desert-olive-dark'\n      case 'info':\n        return 'text-desert-stone-dark'\n      case 'info-inverted':\n        return 'text-desert-tan-dark'\n      default:\n        return 'text-desert-stone-dark'\n    }\n  }\n\n  const getMessageColor = () => {\n    if (variant === 'solid') return 'text-white text-opacity-90'\n\n    switch (type) {\n      case 'warning':\n        return 'text-desert-orange-dark text-opacity-80'\n      case 'error':\n        return 'text-desert-red-dark text-opacity-80'\n      case 'success':\n        return 'text-desert-olive-dark text-opacity-80'\n      case 'info':\n        return 'text-desert-stone-dark text-opacity-80'\n      default:\n        return 'text-desert-stone-dark text-opacity-80'\n    }\n  }\n\n  const getCloseButtonStyles = () => {\n    if (variant === 'solid') {\n      return 'text-white hover:text-white hover:bg-black hover:bg-opacity-20'\n    }\n\n    switch (type) {\n      case 'warning':\n        return 'text-desert-orange hover:text-desert-orange-dark hover:bg-desert-orange-lighter hover:bg-opacity-30'\n      case 'error':\n        return 'text-desert-red hover:text-desert-red-dark hover:bg-desert-red-lighter hover:bg-opacity-30'\n      case 'success':\n        return 'text-desert-olive hover:text-desert-olive-dark hover:bg-desert-olive-lighter hover:bg-opacity-30'\n      case 'info':\n        return 'text-desert-stone hover:text-desert-stone-dark hover:bg-desert-stone-lighter hover:bg-opacity-30'\n      default:\n        return 'text-desert-stone hover:text-desert-stone-dark hover:bg-desert-stone-lighter hover:bg-opacity-30'\n    }\n  }\n\n  return (\n    <div {...props} className={classNames(getVariantStyles(), 'p-5', props.className)} role=\"alert\">\n      <div className=\"flex gap-4 items-center\">\n        <div className=\"flex-shrink-0 mt-0.5\">\n          <DynamicIcon icon={icon || getDefaultIcon()} className={classNames(getIconColor(), 'size-6')} />\n        </div>\n\n        <div className=\"flex-1 min-w-0\">\n          <h3 className={classNames('text-base font-semibold leading-tight', getTitleColor())}>{title}</h3>\n          {message && (\n            <div className={classNames('mt-2 text-sm leading-relaxed', getMessageColor())}>\n              <p>{message}</p>\n            </div>\n          )}\n          {children && <div className=\"mt-3\">{children}</div>}\n        </div>\n\n        {buttonProps && (\n          <div className=\"flex-shrink-0 ml-auto\">\n            <StyledButton {...buttonProps} />\n          </div>\n        )}\n\n        {dismissible && (\n          <button\n            type=\"button\"\n            onClick={onDismiss}\n            className={classNames(\n              'flex-shrink-0 rounded-lg p-1.5 transition-all duration-200',\n              getCloseButtonStyles(),\n              'focus:outline-none focus:ring-2 focus:ring-offset-1',\n              type === 'warning' ? 'focus:ring-desert-orange' : '',\n              type === 'error' ? 'focus:ring-desert-red' : '',\n              type === 'success' ? 'focus:ring-desert-olive' : '',\n              type === 'info' ? 'focus:ring-desert-stone' : ''\n            )}\n            aria-label=\"Dismiss alert\"\n          >\n            <DynamicIcon icon=\"IconX\" className=\"size-4\" />\n          </button>\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/components/BouncingDots.tsx",
    "content": "import clsx from 'clsx'\n\ninterface BouncingDotsProps {\n  text: string\n  containerClassName?: string\n  textClassName?: string\n}\n\nexport default function BouncingDots({ text, containerClassName, textClassName }: BouncingDotsProps) {\n  return (\n    <div className={clsx(\"flex items-center justify-center gap-2\", containerClassName)}>\n      <span className={clsx(\"text-text-secondary\", textClassName)}>{text}</span>\n      <span className=\"flex gap-1 mt-1\">\n        <span\n          className=\"w-1.5 h-1.5 bg-text-secondary rounded-full animate-bounce\"\n          style={{ animationDelay: '0ms' }}\n        />\n        <span\n          className=\"w-1.5 h-1.5 bg-text-secondary rounded-full animate-bounce\"\n          style={{ animationDelay: '150ms' }}\n        />\n        <span\n          className=\"w-1.5 h-1.5 bg-text-secondary rounded-full animate-bounce\"\n          style={{ animationDelay: '300ms' }}\n        />\n      </span>\n    </div>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/components/BouncingLogo.tsx",
    "content": "import { useState, useEffect } from 'react';\n\n// Fading Image Component\nconst FadingImage = ({  alt = \"Fading image\", className = \"\" }) => {\n  const [isVisible, setIsVisible] = useState(true);\n  const [shouldShow, setShouldShow] = useState(true);\n\n  useEffect(() => {\n    // Start fading out after 2 seconds\n    const fadeTimer = setTimeout(() => {\n      setIsVisible(false);\n    }, 2000);\n\n    // Remove from DOM after fade out completes\n    const removeTimer = setTimeout(() => {\n      setShouldShow(false);\n    }, 3000);\n\n    return () => {\n      clearTimeout(fadeTimer);\n      clearTimeout(removeTimer);\n    };\n  }, []);\n\n  if (!shouldShow) {\n    return null;\n  }\n\n  return (\n    <div className={`fixed inset-0 flex justify-center items-center bg-desert-sand z-50 pointer-events-none transition-opacity duration-1000 ${\n      isVisible ? 'opacity-100' : 'opacity-0'\n    }`}>\n      <img\n        src={`/project_nomad_logo.png`}\n        alt={alt}\n        className={`w-64 h-64 ${className}`}\n      />\n    </div>\n  );\n};\n\nexport default FadingImage;"
  },
  {
    "path": "admin/inertia/components/BuilderTagSelector.tsx",
    "content": "import { IconRefresh } from '@tabler/icons-react'\nimport { useState, useEffect } from 'react'\nimport {\n  ADJECTIVES,\n  NOUNS,\n  generateRandomNumber,\n  generateRandomBuilderTag,\n  parseBuilderTag,\n  buildBuilderTag,\n} from '~/lib/builderTagWords'\n\ninterface BuilderTagSelectorProps {\n  value: string | null\n  onChange: (tag: string) => void\n  disabled?: boolean\n}\n\nexport default function BuilderTagSelector({\n  value,\n  onChange,\n  disabled = false,\n}: BuilderTagSelectorProps) {\n  const [adjective, setAdjective] = useState<string>(ADJECTIVES[0])\n  const [noun, setNoun] = useState<string>(NOUNS[0])\n  const [number, setNumber] = useState<string>(generateRandomNumber())\n\n  // Parse existing value on mount\n  useEffect(() => {\n    if (value) {\n      const parsed = parseBuilderTag(value)\n      if (parsed) {\n        setAdjective(parsed.adjective)\n        setNoun(parsed.noun)\n        setNumber(parsed.number)\n      }\n    } else {\n      // Generate a random tag for new users\n      const randomTag = generateRandomBuilderTag()\n      const parsed = parseBuilderTag(randomTag)\n      if (parsed) {\n        setAdjective(parsed.adjective)\n        setNoun(parsed.noun)\n        setNumber(parsed.number)\n        onChange(randomTag)\n      }\n    }\n  }, [])\n\n  // Update parent when selections change\n  const updateTag = (newAdjective: string, newNoun: string, newNumber: string) => {\n    const tag = buildBuilderTag(newAdjective, newNoun, newNumber)\n    onChange(tag)\n  }\n\n  const handleAdjectiveChange = (newAdjective: string) => {\n    setAdjective(newAdjective)\n    updateTag(newAdjective, noun, number)\n  }\n\n  const handleNounChange = (newNoun: string) => {\n    setNoun(newNoun)\n    updateTag(adjective, newNoun, number)\n  }\n\n  const handleRandomize = () => {\n    const newAdjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]\n    const newNoun = NOUNS[Math.floor(Math.random() * NOUNS.length)]\n    const newNumber = generateRandomNumber()\n    setAdjective(newAdjective)\n    setNoun(newNoun)\n    setNumber(newNumber)\n    updateTag(newAdjective, newNoun, newNumber)\n  }\n\n  const currentTag = buildBuilderTag(adjective, noun, number)\n\n  return (\n    <div className=\"space-y-3\">\n      <div className=\"flex flex-wrap items-center gap-2\">\n        <select\n          value={adjective}\n          onChange={(e) => handleAdjectiveChange(e.target.value)}\n          disabled={disabled}\n          className=\"px-3 py-2 bg-desert-stone-lighter border border-desert-stone-light rounded-lg text-desert-green font-medium focus:outline-none focus:ring-2 focus:ring-desert-green disabled:opacity-50\"\n        >\n          {ADJECTIVES.map((adj) => (\n            <option key={adj} value={adj}>\n              {adj}\n            </option>\n          ))}\n        </select>\n\n        <span className=\"text-desert-stone-dark font-bold\">-</span>\n\n        <select\n          value={noun}\n          onChange={(e) => handleNounChange(e.target.value)}\n          disabled={disabled}\n          className=\"px-3 py-2 bg-desert-stone-lighter border border-desert-stone-light rounded-lg text-desert-green font-medium focus:outline-none focus:ring-2 focus:ring-desert-green disabled:opacity-50\"\n        >\n          {NOUNS.map((n) => (\n            <option key={n} value={n}>\n              {n}\n            </option>\n          ))}\n        </select>\n\n        <span className=\"text-desert-stone-dark font-bold\">-</span>\n\n        <span className=\"px-3 py-2 bg-desert-stone-lighter border border-desert-stone-light rounded-lg text-desert-green font-mono font-bold\">\n          {number}\n        </span>\n\n        <button\n          type=\"button\"\n          onClick={handleRandomize}\n          disabled={disabled}\n          className=\"p-2 text-desert-stone-dark hover:text-desert-green hover:bg-desert-stone-lighter rounded-lg transition-colors disabled:opacity-50\"\n          title=\"Randomize\"\n        >\n          <IconRefresh className=\"w-5 h-5\" />\n        </button>\n      </div>\n\n      <div className=\"flex items-center gap-2\">\n        <span className=\"text-sm text-desert-stone-dark\">Your Builder Tag:</span>\n        <span className=\"font-mono font-bold text-desert-green\">{currentTag}</span>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/components/CategoryCard.tsx",
    "content": "import { formatBytes } from '~/lib/util'\nimport DynamicIcon, { DynamicIconName } from './DynamicIcon'\nimport type { CategoryWithStatus, SpecTier } from '../../types/collections'\nimport classNames from 'classnames'\nimport { IconChevronRight, IconCircleCheck } from '@tabler/icons-react'\n\nexport interface CategoryCardProps {\n  category: CategoryWithStatus\n  selectedTier?: SpecTier | null\n  onClick?: (category: CategoryWithStatus) => void\n}\n\nconst CategoryCard: React.FC<CategoryCardProps> = ({ category, selectedTier, onClick }) => {\n  // Calculate total size range across all tiers\n  const getTierTotalSize = (tier: SpecTier, allTiers: SpecTier[]): number => {\n    let total = tier.resources.reduce((acc, r) => acc + r.size_mb * 1024 * 1024, 0)\n\n    // Add included tier sizes recursively\n    if (tier.includesTier) {\n      const includedTier = allTiers.find(t => t.slug === tier.includesTier)\n      if (includedTier) {\n        total += getTierTotalSize(includedTier, allTiers)\n      }\n    }\n\n    return total\n  }\n\n  const minSize = getTierTotalSize(category.tiers[0], category.tiers)\n  const maxSize = getTierTotalSize(category.tiers[category.tiers.length - 1], category.tiers)\n\n  // Determine which tier to highlight: selectedTier (wizard) > installedTierSlug (persisted)\n  const highlightedTierSlug = selectedTier?.slug || category.installedTierSlug\n\n  return (\n    <div\n      className={classNames(\n        'flex flex-col bg-desert-green rounded-lg p-6 text-white border shadow-sm hover:shadow-lg transition-shadow cursor-pointer h-80',\n        selectedTier ? 'border-lime-400 border-2' : 'border-desert-green'\n      )}\n      onClick={() => onClick?.(category)}\n    >\n      <div className=\"flex items-center mb-4\">\n        <div className=\"flex justify-between w-full items-center\">\n          <div className=\"flex items-center\">\n            <DynamicIcon icon={category.icon as DynamicIconName} className=\"w-6 h-6 mr-2\" />\n            <h3 className=\"text-lg font-semibold\">{category.name}</h3>\n          </div>\n          {selectedTier ? (\n            <div className=\"flex items-center\">\n              <IconCircleCheck className=\"w-5 h-5 text-lime-400\" />\n              <span className=\"text-lime-400 text-sm ml-1\">{selectedTier.name}</span>\n            </div>\n          ) : (\n            <IconChevronRight className=\"w-5 h-5 text-white opacity-70\" />\n          )}\n        </div>\n      </div>\n\n      <p className=\"text-gray-200 grow\">{category.description}</p>\n\n      <div className=\"mt-4 pt-4 border-t border-white/20\">\n        <p className=\"text-sm text-gray-300 mb-2\">\n          {category.tiers.length} tiers available\n          {!highlightedTierSlug && (\n            <span className=\"text-gray-400\"> - Click to choose</span>\n          )}\n        </p>\n        <div className=\"flex flex-wrap gap-2\">\n          {category.tiers.map((tier) => {\n            const isInstalled = tier.slug === highlightedTierSlug\n            return (\n              <span\n                key={tier.slug}\n                className={classNames(\n                  'text-xs px-2 py-1 rounded',\n                  isInstalled\n                    ? 'bg-lime-500/30 text-lime-200'\n                    : 'bg-white/10 text-gray-300',\n                  selectedTier?.slug === tier.slug && 'ring-2 ring-lime-400'\n                )}\n              >\n                {tier.name}\n              </span>\n            )\n          })}\n        </div>\n        <p className=\"text-gray-300 text-xs mt-3\">\n          Size: {formatBytes(minSize, 1)} - {formatBytes(maxSize, 1)}\n        </p>\n      </div>\n    </div>\n  )\n}\n\nexport default CategoryCard\n"
  },
  {
    "path": "admin/inertia/components/CuratedCollectionCard.tsx",
    "content": "import { formatBytes } from '~/lib/util'\nimport DynamicIcon, { DynamicIconName } from './DynamicIcon'\nimport type { CollectionWithStatus } from '../../types/collections'\nimport classNames from 'classnames'\nimport { IconCircleCheck } from '@tabler/icons-react'\n\nexport interface CuratedCollectionCardProps {\n  collection: CollectionWithStatus\n  onClick?: (collection: CollectionWithStatus) => void;\n  size?: 'small' | 'large'\n}\n\nconst CuratedCollectionCard: React.FC<CuratedCollectionCardProps> = ({ collection, onClick, size = 'small' }) => {\n  const totalSizeBytes = collection.resources?.reduce(\n    (acc, resource) => acc + resource.size_mb * 1024 * 1024,\n    0\n  )\n  return (\n    <div\n      className={classNames(\n        'flex flex-col bg-desert-green rounded-lg p-6 text-white border border-b-desert-green shadow-sm hover:shadow-lg transition-shadow cursor-pointer',\n        { 'opacity-65 cursor-not-allowed !hover:shadow-sm': collection.all_installed },\n        { 'h-56': size === 'small', 'h-80': size === 'large' }\n      )}\n      onClick={() => {\n        if (collection.all_installed) {\n          return\n        }\n        if (onClick) {\n          onClick(collection)\n        }\n      }}\n    >\n      <div className=\"flex items-center mb-4\">\n        <div className=\"flex justify-between w-full items-center\">\n          <div className=\"flex\">\n            <DynamicIcon icon={collection.icon as DynamicIconName} className=\"w-6 h-6 mr-2\" />\n            <h3 className=\"text-lg font-semibold\">{collection.name}</h3>\n          </div>\n          {collection.all_installed && (\n            <div className=\"flex items-center\">\n              <IconCircleCheck\n                className=\"w-5 h-5 text-lime-400 ml-2\"\n                title=\"All items downloaded\"\n              />\n              <p className=\"text-lime-400 text-sm ml-1\">All items downloaded</p>\n            </div>\n          )}\n        </div>\n      </div>\n      <p className=\"text-gray-200 grow\">{collection.description}</p>\n      <p className=\"text-gray-200 text-xs mt-2\">\n        Items: {collection.resources?.length} | Size: {formatBytes(totalSizeBytes, 0)}\n      </p>\n    </div>\n  )\n}\nexport default CuratedCollectionCard\n"
  },
  {
    "path": "admin/inertia/components/DebugInfoModal.tsx",
    "content": "import { useEffect, useState } from 'react'\nimport { IconBug, IconCopy, IconCheck } from '@tabler/icons-react'\nimport StyledModal from './StyledModal'\nimport api from '~/lib/api'\n\ninterface DebugInfoModalProps {\n  open: boolean\n  onClose: () => void\n}\n\nexport default function DebugInfoModal({ open, onClose }: DebugInfoModalProps) {\n  const [debugText, setDebugText] = useState('')\n  const [loading, setLoading] = useState(false)\n  const [copied, setCopied] = useState(false)\n\n  useEffect(() => {\n    if (!open) return\n\n    setLoading(true)\n    setCopied(false)\n\n    api.getDebugInfo().then((text) => {\n      if (text) {\n        const browserLine = `Browser: ${navigator.userAgent}`\n        setDebugText(text + '\\n' + browserLine)\n      } else {\n        setDebugText('Failed to load debug info. Please try again.')\n      }\n      setLoading(false)\n    }).catch(() => {\n      setDebugText('Failed to load debug info. Please try again.')\n      setLoading(false)\n    })\n  }, [open])\n\n  const handleCopy = async () => {\n    try {\n      await navigator.clipboard.writeText(debugText)\n    } catch {\n      // Fallback for older browsers\n      const textarea = document.querySelector<HTMLTextAreaElement>('#debug-info-text')\n      if (textarea) {\n        textarea.select()\n        document.execCommand('copy')\n      }\n    }\n    setCopied(true)\n    setTimeout(() => setCopied(false), 2000)\n  }\n\n  return (\n    <StyledModal\n      open={open}\n      onClose={onClose}\n      title=\"Debug Info\"\n      icon={<IconBug className=\"size-8 text-desert-green\" />}\n      cancelText=\"Close\"\n      onCancel={onClose}\n    >\n      <p className=\"text-sm text-gray-500 mb-3 text-left\">\n        This is non-sensitive system info you can share when reporting issues.\n        No passwords, IPs, or API keys are included.\n      </p>\n\n      <textarea\n        id=\"debug-info-text\"\n        readOnly\n        value={loading ? 'Loading...' : debugText}\n        rows={18}\n        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\"\n      />\n\n      <div className=\"mt-3 flex items-center justify-between\">\n        <button\n          onClick={handleCopy}\n          disabled={loading}\n          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\"\n        >\n          {copied ? (\n            <>\n              <IconCheck className=\"size-4\" />\n              Copied!\n            </>\n          ) : (\n            <>\n              <IconCopy className=\"size-4\" />\n              Copy to Clipboard\n            </>\n          )}\n        </button>\n\n        <a\n          href=\"https://github.com/Crosstalk-Solutions/project-nomad/issues\"\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n          className=\"text-sm text-desert-green hover:underline\"\n        >\n          Open a GitHub Issue\n        </a>\n      </div>\n    </StyledModal>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/components/DownloadURLModal.tsx",
    "content": "import { useState } from 'react'\nimport StyledModal, { StyledModalProps } from './StyledModal'\nimport Input from './inputs/Input'\nimport api from '~/lib/api'\n\nexport type DownloadURLModalProps = Omit<\n  StyledModalProps,\n  'onConfirm' | 'open' | 'confirmText' | 'cancelText' | 'confirmVariant' | 'children'\n> & {\n  suggestedURL?: string\n  onPreflightSuccess?: (url: string) => void\n}\n\nconst DownloadURLModal: React.FC<DownloadURLModalProps> = ({\n  suggestedURL,\n  onPreflightSuccess,\n  ...modalProps\n}) => {\n  const [url, setUrl] = useState<string>('')\n  const [messages, setMessages] = useState<string[]>([])\n  const [loading, setLoading] = useState<boolean>(false)\n\n  async function runPreflightCheck(downloadUrl: string) {\n    try {\n      setLoading(true)\n      setMessages([`Running preflight check for URL: ${downloadUrl}`])\n      const res = await api.downloadRemoteMapRegionPreflight(downloadUrl)\n      if (!res) {\n        throw new Error('An unknown error occurred during the preflight check.')\n      }\n\n      if ('message' in res) {\n        throw new Error(res.message)\n      }\n\n      setMessages((prev) => [\n        ...prev,\n        `Preflight check passed. Filename: ${res.filename}, Size: ${(res.size / (1024 * 1024)).toFixed(2)} MB`,\n      ])\n\n      if (onPreflightSuccess) {\n        onPreflightSuccess(downloadUrl)\n      }\n    } catch (error) {\n      console.error('Preflight check failed:', error)\n      setMessages((prev) => [...prev, `Preflight check failed: ${error.message}`])\n    } finally {\n      setLoading(false)\n    }\n  }\n\n  return (\n    <StyledModal\n      {...modalProps}\n      onConfirm={() => runPreflightCheck(url)}\n      open={true}\n      confirmText=\"Download\"\n      confirmIcon=\"IconDownload\"\n      cancelText=\"Cancel\"\n      confirmVariant=\"primary\"\n      confirmLoading={loading}\n      cancelLoading={loading}\n      large\n    >\n      <div className=\"flex flex-col pb-4\">\n        <p className=\"text-text-secondary mb-8\">\n          Enter the URL of the map region file you wish to download. The URL must be publicly\n          reachable and end with .pmtiles. A preflight check will be run to verify the file's\n          availability, type, and approximate size.\n        </p>\n        <Input\n          name=\"download-url\"\n          label=\"\"\n          placeholder={suggestedURL || 'Enter download URL...'}\n          className=\"mb-4\"\n          value={url}\n          onChange={(e) => setUrl(e.target.value)}\n        />\n        <div className=\"min-h-24 max-h-96 overflow-y-auto bg-surface-secondary p-4 rounded border border-border-default text-left\">\n          {messages.map((message, idx) => (\n            <p\n              key={idx}\n              className=\"text-sm text-text-primary font-mono leading-relaxed break-words mb-3\"\n            >\n              {message}\n            </p>\n          ))}\n        </div>\n      </div>\n    </StyledModal>\n  )\n}\n\nexport default DownloadURLModal\n"
  },
  {
    "path": "admin/inertia/components/DynamicIcon.tsx",
    "content": "import classNames from 'classnames'\nimport * as TablerIcons from '@tabler/icons-react'\n\nexport type DynamicIconName = keyof typeof TablerIcons\n\ninterface DynamicIconProps {\n  icon?: DynamicIconName\n  className?: string\n  stroke?: number\n  onClick?: () => void\n}\n\n/**\n * Renders a dynamic icon from the TablerIcons library based on the provided icon name.\n * @param icon - The name of the icon to render.\n * @param className - Optional additional CSS classes to apply to the icon.\n * @param stroke - Optional stroke width for the icon.\n * @returns A React element representing the icon, or null if no matching icon is found.\n */\nconst DynamicIcon: React.FC<DynamicIconProps> = ({ icon, className, stroke, onClick }) => {\n  if (!icon) return null\n\n  const Icon = TablerIcons[icon]\n\n  if (!Icon) {\n    console.warn(`Icon \"${icon}\" not found in TablerIcons.`)\n    return null\n  }\n\n  return (\n    // @ts-ignore\n    <Icon className={classNames('h-5 w-5', className)} stroke={stroke || 2} onClick={onClick} />\n  )\n}\n\nexport default DynamicIcon\n"
  },
  {
    "path": "admin/inertia/components/Footer.tsx",
    "content": "import { useState } from 'react'\nimport { usePage } from '@inertiajs/react'\nimport { UsePageProps } from '../../types/system'\nimport ThemeToggle from '~/components/ThemeToggle'\nimport { IconBug } from '@tabler/icons-react'\nimport DebugInfoModal from './DebugInfoModal'\n\nexport default function Footer() {\n  const { appVersion } = usePage().props as unknown as UsePageProps\n  const [debugModalOpen, setDebugModalOpen] = useState(false)\n\n  return (\n    <footer>\n      <div className=\"flex items-center justify-center gap-3 border-t border-border-subtle py-4\">\n        <p className=\"text-sm/6 text-text-secondary\">\n          Project N.O.M.A.D. Command Center v{appVersion}\n        </p>\n        <span className=\"text-gray-300\">|</span>\n        <button\n          onClick={() => setDebugModalOpen(true)}\n          className=\"text-sm/6 text-gray-500 hover:text-desert-green flex items-center gap-1 cursor-pointer\"\n        >\n          <IconBug className=\"size-3.5\" />\n          Debug Info\n        </button>\n        <ThemeToggle />\n      </div>\n      <DebugInfoModal open={debugModalOpen} onClose={() => setDebugModalOpen(false)} />\n    </footer>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/components/HorizontalBarChart.tsx",
    "content": "import classNames from '~/lib/classNames'\n\ninterface HorizontalBarChartProps {\n  items: Array<{\n    label: string\n    value: number // percentage\n    total: string\n    used: string\n    type?: string\n  }>\n  statuses?: Array<{\n    label: string\n    min_threshold: number\n    color_class: string\n  }>\n  progressiveBarColor?: boolean\n}\n\nexport default function HorizontalBarChart({\n  items,\n  statuses,\n  progressiveBarColor = false,\n}: HorizontalBarChartProps) {\n  const sortedStatus = statuses?.sort((a, b) => b.min_threshold - a.min_threshold) || []\n\n  const getBarColor = (value: number) => {\n    if (!progressiveBarColor) return 'bg-desert-green'\n    if (value >= 90) return 'bg-desert-red'\n    if (value >= 75) return 'bg-desert-orange'\n    if (value >= 50) return 'bg-desert-tan'\n    return 'bg-desert-olive'\n  }\n\n  const getGlowColor = (value: number) => {\n    if (value >= 90) return 'shadow-desert-red/50'\n    if (value >= 75) return 'shadow-desert-orange/50'\n    if (value >= 50) return 'shadow-desert-tan/50'\n    return 'shadow-desert-olive/50'\n  }\n\n  const getStatusLabel = (value: number) => {\n    if (sortedStatus.length === 0) return ''\n    for (const status of sortedStatus) {\n      if (value >= status.min_threshold) {\n        return status.label\n      }\n    }\n    return ''\n  }\n\n  const getStatusColor = (value: number) => {\n    if (sortedStatus.length === 0) return ''\n    for (const status of sortedStatus) {\n      if (value >= status.min_threshold) {\n        return status.color_class\n      }\n    }\n    return ''\n  }\n\n  return (\n    <div className=\"space-y-6\">\n      {items.map((item, index) => (\n        <div key={index} className=\"space-y-2\">\n          <div className=\"flex justify-between items-baseline\">\n            <div className=\"flex items-center gap-2\">\n              <span className=\"font-semibold text-desert-green\">{item.label}</span>\n              {item.type && (\n                <span className=\"text-xs px-2 py-0.5 rounded bg-desert-stone-lighter text-desert-stone-dark font-mono\">\n                  {item.type}\n                </span>\n              )}\n            </div>\n            <div className=\"text-sm text-desert-stone-dark font-mono\">\n              {item.used} / {item.total}\n            </div>\n          </div>\n          <div className=\"relative\">\n            <div className=\"h-8 bg-desert-green-lighter bg-opacity-20 rounded-lg border border-desert-stone-light overflow-hidden\">\n              <div\n                className={classNames(\n                  'h-full rounded-lg transition-all duration-1000 ease-out relative overflow-hidden',\n                  getBarColor(item.value),\n                  'shadow-lg',\n                  getGlowColor(item.value)\n                )}\n                style={{\n                  width: `${item.value}%`,\n                  animationDelay: `${index * 100}ms`,\n                }}\n              ></div>\n            </div>\n            <div\n              className={classNames(\n                'absolute top-1/2 -translate-y-1/2 font-bold text-sm',\n                item.value > 15\n                  ? 'left-3 text-white drop-shadow-md'\n                  : 'right-3 text-desert-green'\n              )}\n            >\n              {Math.round(item.value)}%\n            </div>\n          </div>\n          {getStatusLabel(item.value) && (\n            <div className=\"flex items-center gap-2\">\n              <div\n                className={classNames(\n                  'w-2 h-2 rounded-full animate-pulse',\n                  getStatusColor(item.value)\n                )}\n              />\n              <span className=\"text-xs text-desert-stone\">{getStatusLabel(item.value)}</span>\n            </div>\n          )}\n        </div>\n      ))}\n    </div>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/components/InfoTooltip.tsx",
    "content": "import { IconInfoCircle } from '@tabler/icons-react'\nimport { useState } from 'react'\n\ninterface InfoTooltipProps {\n  text: string\n  className?: string\n}\n\nexport default function InfoTooltip({ text, className = '' }: InfoTooltipProps) {\n  const [isVisible, setIsVisible] = useState(false)\n\n  return (\n    <span className={`relative inline-flex items-center ${className}`}>\n      <button\n        type=\"button\"\n        className=\"text-desert-stone-dark hover:text-desert-green transition-colors p-0.5\"\n        onMouseEnter={() => setIsVisible(true)}\n        onMouseLeave={() => setIsVisible(false)}\n        onFocus={() => setIsVisible(true)}\n        onBlur={() => setIsVisible(false)}\n        aria-label=\"More information\"\n      >\n        <IconInfoCircle className=\"w-4 h-4\" />\n      </button>\n      {isVisible && (\n        <div className=\"absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50\">\n          <div className=\"bg-desert-stone-dark text-white text-xs rounded-lg px-3 py-2 max-w-xs whitespace-normal shadow-lg\">\n            {text}\n            <div className=\"absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-desert-stone-dark\" />\n          </div>\n        </div>\n      )}\n    </span>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/components/InstallActivityFeed.tsx",
    "content": "import { IconCircleCheck, IconCircleX } from '@tabler/icons-react'\nimport classNames from '~/lib/classNames'\n\nexport type InstallActivityFeedProps = {\n  activity: Array<{\n    service_name: string\n    type:\n      | 'initializing'\n      | 'pulling'\n      | 'pulled'\n      | 'creating'\n      | 'created'\n      | 'preinstall'\n      | 'preinstall-complete'\n      | 'starting'\n      | 'started'\n      | 'finalizing'\n      | 'completed'\n      | 'update-pulling'\n      | 'update-stopping'\n      | 'update-creating'\n      | 'update-starting'\n      | 'update-complete'\n      | 'update-rollback'\n    timestamp: string\n    message: string\n  }>\n  className?: string\n  withHeader?: boolean\n}\n\nconst InstallActivityFeed: React.FC<InstallActivityFeedProps> = ({ activity, className, withHeader = false }) => {\n  return (\n    <div className={classNames('bg-surface-primary shadow-sm rounded-lg p-6', className)}>\n      {withHeader && <h2 className=\"text-lg font-semibold text-text-primary\">Installation Activity</h2>}\n      <ul role=\"list\" className={classNames(\"space-y-6 text-desert-green\", withHeader ? 'mt-6' : '')}>\n        {activity.map((activityItem, activityItemIdx) => (\n          <li key={activityItem.timestamp} className=\"relative flex gap-x-4\">\n            <div\n              className={classNames(\n                activityItemIdx === activity.length - 1 ? 'h-6' : '-bottom-6',\n                'absolute left-0 top-0 flex w-6 justify-center'\n              )}\n            >\n              <div className=\"w-px bg-border-subtle\" />\n            </div>\n            <>\n              <div className=\"relative flex size-6 flex-none items-center justify-center bg-transparent\">\n                {activityItem.type === 'completed' || activityItem.type === 'update-complete' ? (\n                  <IconCircleCheck aria-hidden=\"true\" className=\"size-6 text-indigo-600\" />\n                ) : activityItem.type === 'update-rollback' ? (\n                  <IconCircleX aria-hidden=\"true\" className=\"size-6 text-red-500\" />\n                ) : (\n                  <div className=\"size-1.5 rounded-full bg-surface-secondary ring-1 ring-border-default\" />\n                )}\n              </div>\n              <p className=\"flex-auto py-0.5 text-xs/5 text-text-muted\">\n                <span className=\"font-semibold text-text-primary\">{activityItem.service_name}</span> -{' '}\n                {activityItem.type.charAt(0).toUpperCase() + activityItem.type.slice(1)}\n              </p>\n              <time\n                dateTime={activityItem.timestamp}\n                className=\"flex-none py-0.5 text-xs/5 text-text-muted\"\n              >\n                {activityItem.timestamp}\n              </time>\n            </>\n          </li>\n        ))}\n      </ul>\n    </div>\n  )\n}\n\nexport default InstallActivityFeed\n"
  },
  {
    "path": "admin/inertia/components/LoadingSpinner.tsx",
    "content": "interface LoadingSpinnerProps {\n  text?: string\n  fullscreen?: boolean\n  iconOnly?: boolean\n  light?: boolean\n  className?: string\n}\n\nconst LoadingSpinner: React.FC<LoadingSpinnerProps> = ({\n  text,\n  fullscreen = true,\n  iconOnly = false,\n  light = false,\n  className,\n}) => {\n  if (!fullscreen) {\n    return (\n      <div className=\"flex flex-col items-center justify-center\">\n        <div\n          className={`w-8 h-8 border-[3px] ${light ? 'border-white' : 'border-text-muted'} border-t-transparent rounded-full animate-spin ${className || ''}`}\n        ></div>\n        {!iconOnly && (\n          <div className={light ? 'text-white mt-2' : 'text-text-primary mt-2'}>\n            {text || 'Loading...'}\n          </div>\n        )}\n      </div>\n    )\n  }\n\n  return (\n    <div className={className}>\n      <div className=\"ui active inverted dimmer\">\n        <div className=\"ui text loader\">{!iconOnly && <span>{text || 'Loading'}</span>}</div>\n      </div>\n    </div>\n  )\n}\n\nexport default LoadingSpinner\n"
  },
  {
    "path": "admin/inertia/components/MarkdocRenderer.tsx",
    "content": "import React from 'react'\nimport Markdoc from '@markdoc/markdoc'\nimport { Heading } from './markdoc/Heading'\nimport { List } from './markdoc/List'\nimport { ListItem } from './markdoc/ListItem'\nimport { Image } from './markdoc/Image'\nimport { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from './markdoc/Table'\n\n// Paragraph component\nconst Paragraph = ({ children }: { children: React.ReactNode }) => {\n  return <p className=\"mb-4 leading-relaxed text-desert-green-darker/85\">{children}</p>\n}\n\n// Link component\nconst Link = ({\n  href,\n  title,\n  children,\n}: {\n  href: string\n  title?: string\n  children: React.ReactNode\n}) => {\n  const isExternal = href?.startsWith('http')\n  return (\n    <a\n      href={href}\n      title={title}\n      className=\"text-desert-orange font-medium hover:text-desert-orange-dark underline decoration-desert-orange-lighter/50 underline-offset-2 hover:decoration-desert-orange transition-colors\"\n      {...(isExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {})}\n    >\n      {children}\n    </a>\n  )\n}\n\n// Inline code component\nconst InlineCode = ({ content, children }: { content?: string; children?: React.ReactNode }) => {\n  return (\n    <code className=\"bg-desert-green-lighter/30 text-desert-green-darker border border-desert-green-lighter/50 px-1.5 py-0.5 rounded text-[0.875em] font-mono\">\n      {content || children}\n    </code>\n  )\n}\n\n// Code block component\nconst CodeBlock = ({\n  content,\n  language,\n  children,\n}: {\n  content?: string\n  language?: string\n  children?: React.ReactNode\n}) => {\n  const code = content || (typeof children === 'string' ? children : '')\n  return (\n    <div className=\"my-6 overflow-hidden rounded-lg border border-desert-green-dark/20\">\n      {language && (\n        <div className=\"bg-desert-green-dark px-4 py-1.5 text-xs font-mono text-desert-green-lighter uppercase tracking-wider\">\n          {language}\n        </div>\n      )}\n      <pre className=\"bg-desert-green-darker overflow-x-auto p-4\">\n        <code className=\"text-sm font-mono text-desert-green-lighter leading-relaxed whitespace-pre\">\n          {code}\n        </code>\n      </pre>\n    </div>\n  )\n}\n\n// Horizontal rule component\nconst HorizontalRule = () => {\n  return (\n    <hr className=\"my-10 border-0 h-px bg-gradient-to-r from-transparent via-desert-tan-lighter to-transparent\" />\n  )\n}\n\n// Callout component\nconst Callout = ({\n  type = 'info',\n  title,\n  children,\n}: {\n  type?: string\n  title?: string\n  children: React.ReactNode\n}) => {\n  const styles: Record<string, string> = {\n    info: 'bg-desert-sand/60 border-desert-olive text-desert-green-darker',\n    warning: 'bg-desert-orange-lighter/15 border-desert-orange text-desert-green-darker',\n    error: 'bg-desert-red-lighter/15 border-desert-red text-desert-green-darker',\n    success: 'bg-desert-olive-lighter/15 border-desert-olive text-desert-green-darker',\n  }\n\n  return (\n    <div className={`border-l-4 rounded-r-lg p-5 mb-6 ${styles[type] || styles.info}`}>\n      {title && <h4 className=\"font-semibold mb-2\">{title}</h4>}\n      <div className=\"[&>p:last-child]:mb-0\">{children}</div>\n    </div>\n  )\n}\n\n// Component mapping for Markdoc\nconst components = {\n  Paragraph,\n  Image,\n  Link,\n  InlineCode,\n  CodeBlock,\n  HorizontalRule,\n  Callout,\n  Heading,\n  List,\n  ListItem,\n  Table,\n  TableHead,\n  TableBody,\n  TableRow,\n  TableHeader,\n  TableCell,\n}\n\ninterface MarkdocRendererProps {\n  content: any // Markdoc transformed content\n}\n\nconst MarkdocRenderer: React.FC<MarkdocRendererProps> = ({ content }) => {\n  return (\n    <div className=\"text-base tracking-wide\">{Markdoc.renderers.react(content, React, { components })}</div>\n  )\n}\n\nexport default MarkdocRenderer\n"
  },
  {
    "path": "admin/inertia/components/ProgressBar.tsx",
    "content": "const ProgressBar = ({ progress, speed }: { progress: number; speed?: string }) => {\n  if (progress >= 100) {\n    return (\n      <div className=\"flex items-center justify-between\">\n        <span className=\"text-sm text-desert-green\">Download complete</span>\n      </div>\n    )\n  }\n\n  return (\n    <div className=\"flex flex-col\">\n      <div className=\"relative w-full h-2 bg-border-subtle rounded\">\n        <div\n          className=\"absolute top-0 left-0 h-full bg-desert-green rounded\"\n          style={{ width: `${progress}%` }}\n        />\n      </div>\n      {speed && (\n        <div className=\"mt-1 text-sm text-text-muted\">\n          Est. Speed: {speed}\n        </div>\n      )}\n    </div>\n  )\n}\n\nexport default ProgressBar\n"
  },
  {
    "path": "admin/inertia/components/StorageProjectionBar.tsx",
    "content": "import classNames from '~/lib/classNames'\nimport { formatBytes } from '~/lib/util'\nimport { IconAlertTriangle, IconServer } from '@tabler/icons-react'\n\ninterface StorageProjectionBarProps {\n  totalSize: number // Total disk size in bytes\n  currentUsed: number // Currently used space in bytes\n  projectedAddition: number // Additional space that will be used in bytes\n}\n\nexport default function StorageProjectionBar({\n  totalSize,\n  currentUsed,\n  projectedAddition,\n}: StorageProjectionBarProps) {\n  const projectedTotal = currentUsed + projectedAddition\n  const currentPercent = (currentUsed / totalSize) * 100\n  const projectedPercent = (projectedAddition / totalSize) * 100\n  const projectedTotalPercent = (projectedTotal / totalSize) * 100\n  const remainingAfter = totalSize - projectedTotal\n  const willExceed = projectedTotal > totalSize\n\n  // Determine warning level based on projected total\n  const getProjectedColor = () => {\n    if (willExceed) return 'bg-desert-red'\n    if (projectedTotalPercent >= 90) return 'bg-desert-orange'\n    if (projectedTotalPercent >= 75) return 'bg-desert-tan'\n    return 'bg-desert-olive'\n  }\n\n  const getProjectedGlow = () => {\n    if (willExceed) return 'shadow-desert-red/50'\n    if (projectedTotalPercent >= 90) return 'shadow-desert-orange/50'\n    if (projectedTotalPercent >= 75) return 'shadow-desert-tan/50'\n    return 'shadow-desert-olive/50'\n  }\n\n  return (\n    <div className=\"bg-desert-stone-lighter/30 rounded-lg p-4 border border-desert-stone-light\">\n      <div className=\"flex items-center justify-between mb-3\">\n        <div className=\"flex items-center gap-2\">\n          <IconServer size={20} className=\"text-desert-green\" />\n          <span className=\"font-semibold text-desert-green\">Storage</span>\n        </div>\n        <div className=\"text-sm text-desert-stone-dark font-mono\">\n          {formatBytes(projectedTotal, 1)} / {formatBytes(totalSize, 1)}\n          {projectedAddition > 0 && (\n            <span className=\"text-desert-stone ml-2\">\n              (+{formatBytes(projectedAddition, 1)} selected)\n            </span>\n          )}\n        </div>\n      </div>\n\n      {/* Progress bar */}\n      <div className=\"relative\">\n        <div className=\"h-8 bg-desert-green-lighter/20 rounded-lg border border-desert-stone-light overflow-hidden\">\n          {/* Current usage - darker/subdued */}\n          <div\n            className=\"absolute h-full bg-desert-stone transition-all duration-300\"\n            style={{ width: `${Math.min(currentPercent, 100)}%` }}\n          />\n          {/* Projected addition - highlighted */}\n          {projectedAddition > 0 && (\n            <div\n              className={classNames(\n                'absolute h-full transition-all duration-300 shadow-lg',\n                getProjectedColor(),\n                getProjectedGlow()\n              )}\n              style={{\n                left: `${Math.min(currentPercent, 100)}%`,\n                width: `${Math.min(projectedPercent, 100 - currentPercent)}%`,\n              }}\n            />\n          )}\n        </div>\n\n        {/* Percentage label */}\n        <div\n          className={classNames(\n            'absolute top-1/2 -translate-y-1/2 font-bold text-sm',\n            projectedTotalPercent > 15\n              ? 'left-3 text-white drop-shadow-md'\n              : 'right-3 text-desert-green'\n          )}\n        >\n          {Math.round(projectedTotalPercent)}%\n        </div>\n      </div>\n\n      {/* Legend and warnings */}\n      <div className=\"flex items-center justify-between mt-3\">\n        <div className=\"flex items-center gap-4 text-xs\">\n          <div className=\"flex items-center gap-1.5\">\n            <div className=\"w-3 h-3 rounded bg-desert-stone\" />\n            <span className=\"text-desert-stone-dark\">Current ({formatBytes(currentUsed, 1)})</span>\n          </div>\n          {projectedAddition > 0 && (\n            <div className=\"flex items-center gap-1.5\">\n              <div className={classNames('w-3 h-3 rounded', getProjectedColor())} />\n              <span className=\"text-desert-stone-dark\">\n                Selected (+{formatBytes(projectedAddition, 1)})\n              </span>\n            </div>\n          )}\n        </div>\n\n        {willExceed ? (\n          <div className=\"flex items-center gap-1.5 text-desert-red text-xs font-medium\">\n            <IconAlertTriangle size={14} />\n            <span>Exceeds available space by {formatBytes(projectedTotal - totalSize, 1)}</span>\n          </div>\n        ) : (\n          <div className=\"text-xs text-desert-stone\">\n            {formatBytes(remainingAfter, 1)} will remain free\n          </div>\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/components/StyledButton.tsx",
    "content": "import { useMemo } from 'react'\nimport clsx from 'clsx'\nimport DynamicIcon, { DynamicIconName} from './DynamicIcon'\nimport { IconRefresh } from '@tabler/icons-react'\n\nexport interface StyledButtonProps extends React.HTMLAttributes<HTMLButtonElement> {\n  children: React.ReactNode\n  icon?: DynamicIconName\n  disabled?: boolean\n  variant?: 'primary' | 'secondary' | 'danger' | 'action' | 'success' | 'ghost' | 'outline'\n  size?: 'sm' | 'md' | 'lg'\n  loading?: boolean\n  fullWidth?: boolean\n}\n\nconst StyledButton: React.FC<StyledButtonProps> = ({\n  children,\n  icon,\n  variant = 'primary',\n  size = 'md',\n  loading = false,\n  fullWidth = false,\n  className,\n  ...props\n}) => {\n  const isDisabled = useMemo(() => {\n    return props.disabled || loading\n  }, [props.disabled, loading])\n\n  const getIconSize = () => {\n    switch (size) {\n      case 'sm':\n        return 'h-3.5 w-3.5 mr-1.5'\n      case 'lg':\n        return 'h-5 w-5 mr-2.5'\n      default:\n        return 'h-4 w-4 mr-2'\n    }\n  }\n\n  const getSizeClasses = () => {\n    switch (size) {\n      case 'sm':\n        return 'px-2.5 py-1.5 text-xs'\n      case 'lg':\n        return 'px-4 py-3 text-base'\n      default:\n        return 'px-3 py-2 text-sm'\n    }\n  }\n\n  const getVariantClasses = () => {\n    const baseTransition = 'transition-all duration-200 ease-in-out'\n    const baseHover = 'hover:shadow-md active:scale-[0.98]'\n\n    switch (variant) {\n      case 'primary':\n        return clsx(\n          'bg-desert-green text-white',\n          'hover:bg-btn-green-hover hover:shadow-lg',\n          'active:bg-btn-green-active',\n          'disabled:bg-desert-green-light disabled:text-desert-stone-light',\n          baseTransition,\n          baseHover\n        )\n\n      case 'secondary':\n        return clsx(\n          'bg-desert-tan text-white',\n          'hover:bg-desert-tan-dark hover:shadow-lg',\n          'active:bg-desert-tan-dark',\n          'disabled:bg-desert-tan-lighter disabled:text-desert-stone-light',\n          baseTransition,\n          baseHover\n        )\n\n      case 'danger':\n        return clsx(\n          'bg-desert-red text-white',\n          'hover:bg-desert-red-dark hover:shadow-lg',\n          'active:bg-desert-red-dark',\n          'disabled:bg-desert-red-lighter disabled:text-desert-stone-light',\n          baseTransition,\n          baseHover\n        )\n\n      case 'action':\n        return clsx(\n          'bg-desert-orange text-white',\n          'hover:bg-desert-orange-light hover:shadow-lg',\n          'active:bg-desert-orange-dark',\n          'disabled:bg-desert-orange-lighter disabled:text-desert-stone-light',\n          baseTransition,\n          baseHover\n        )\n\n      case 'success':\n        return clsx(\n          'bg-desert-olive text-white',\n          'hover:bg-desert-olive-dark hover:shadow-lg',\n          'active:bg-desert-olive-dark',\n          'disabled:bg-desert-olive-lighter disabled:text-desert-stone-light',\n          baseTransition,\n          baseHover\n        )\n\n      case 'ghost':\n        return clsx(\n          'bg-transparent text-desert-green',\n          'hover:bg-desert-sand hover:text-desert-green-dark',\n          'active:bg-desert-green-lighter',\n          'disabled:text-desert-stone-light',\n          baseTransition\n        )\n\n      case 'outline':\n        return clsx(\n          'bg-transparent border-2 border-desert-green text-desert-green',\n          'hover:bg-desert-green hover:text-white hover:border-btn-green-hover',\n          'active:bg-btn-green-hover active:border-btn-green-active',\n          'disabled:border-desert-green-lighter disabled:text-desert-stone-light',\n          baseTransition,\n          baseHover\n        )\n\n      default:\n        return ''\n    }\n  }\n\n  const getLoadingSpinner = () => {\n    const spinnerSize = size === 'sm' ? 'h-3.5 w-3.5' : size === 'lg' ? 'h-5 w-5' : 'h-4 w-4'\n    return (\n      <IconRefresh\n        className={clsx(spinnerSize, 'animate-spin')}\n      />\n    )\n  }\n\n  const onClickHandler = (e: React.MouseEvent<HTMLButtonElement>) => {\n    if (isDisabled) {\n      e.preventDefault()\n      return\n    }\n    props.onClick?.(e)\n  }\n\n  return (\n    <button\n      type=\"button\"\n      className={clsx(\n        fullWidth ? 'flex w-full' : 'inline-flex',\n        getSizeClasses(),\n        getVariantClasses(),\n        isDisabled ? 'pointer-events-none opacity-60' : 'cursor-pointer',\n        'items-center justify-center rounded-md font-semibold focus:outline-none focus:ring-2 focus:ring-desert-green-light focus:ring-offset-2 focus:ring-offset-desert-sand disabled:cursor-not-allowed disabled:shadow-none',\n        className\n      )}\n      {...props}\n      disabled={isDisabled}\n      onClick={onClickHandler}\n    >\n      {loading ? (\n        getLoadingSpinner()\n      ) : (\n        <>\n          {icon && <DynamicIcon icon={icon} className={getIconSize()} />}\n          {children}\n        </>\n      )}\n    </button>\n  )\n}\n\nexport default StyledButton\n"
  },
  {
    "path": "admin/inertia/components/StyledModal.tsx",
    "content": "import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react'\nimport StyledButton, { StyledButtonProps } from './StyledButton'\nimport React from 'react'\nimport classNames from '~/lib/classNames'\n\nexport type StyledModalProps = {\n  onClose?: () => void\n  title: string\n  cancelText?: string\n  cancelIcon?: StyledButtonProps['icon']\n  cancelLoading?: boolean\n  confirmText?: string\n  confirmIcon?: StyledButtonProps['icon']\n  confirmVariant?: StyledButtonProps['variant']\n  confirmLoading?: boolean\n  open: boolean\n  onCancel?: () => void\n  onConfirm?: () => void\n  children: React.ReactNode\n  icon?: React.ReactNode\n  large?: boolean\n}\n\nconst StyledModal: React.FC<StyledModalProps> = ({\n  children,\n  title,\n  open,\n  onClose,\n  cancelText = 'Cancel',\n  cancelIcon,\n  cancelLoading = false,\n  confirmText = 'Confirm',\n  confirmIcon,\n  confirmVariant = 'action',\n  confirmLoading = false,\n  onCancel,\n  onConfirm,\n  icon,\n  large = false,\n}) => {\n  return (\n    <Dialog\n      open={open}\n      onClose={() => {\n        if (onClose) onClose()\n      }}\n      className=\"relative z-50\"\n    >\n      <DialogBackdrop\n        transition\n        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\"\n      />\n      <div className=\"fixed inset-0 z-10 w-screen overflow-y-auto\">\n        <div\n          className={classNames(\n            'flex min-h-full items-end justify-center p-4 text-center sm:items-center !w-screen',\n            large ? 'sm:px-4' : 'sm:p-0'\n          )}\n        >\n          <DialogPanel\n            transition\n            className={classNames(\n              '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',\n              large ? 'sm:max-w-7xl !w-full' : 'sm:max-w-lg'\n            )}\n          >\n            <div>\n              {icon && <div className=\"flex items-center justify-center\">{icon}</div>}\n              <div className=\"mt-3 text-center sm:mt-5\">\n                <DialogTitle as=\"h3\" className=\"text-base font-semibold text-text-primary\">\n                  {title}\n                </DialogTitle>\n                <div className=\"mt-2 !h-fit\">{children}</div>\n              </div>\n            </div>\n            <div className=\"mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3\">\n              {cancelText && onCancel && (\n                <StyledButton\n                  variant=\"outline\"\n                  onClick={() => {\n                    if (onCancel) onCancel()\n                  }}\n                  icon={cancelIcon}\n                  loading={cancelLoading}\n                >\n                  {cancelText}\n                </StyledButton>\n              )}\n              {confirmText && onConfirm && (\n                <StyledButton\n                  variant={confirmVariant}\n                  onClick={() => {\n                    if (onConfirm) onConfirm()\n                  }}\n                  icon={confirmIcon}\n                  loading={confirmLoading}\n                >\n                  {confirmText}\n                </StyledButton>\n              )}\n            </div>\n          </DialogPanel>\n        </div>\n      </div>\n    </Dialog>\n  )\n}\n\nexport default StyledModal\n"
  },
  {
    "path": "admin/inertia/components/StyledSectionHeader.tsx",
    "content": "import classNames from 'classnames'\nimport { JSX } from 'react'\n\nexport interface StyledSectionHeaderProps {\n  title: string\n  level?: 1 | 2 | 3 | 4 | 5 | 6\n  className?: string\n}\n\nconst StyledSectionHeader = ({ title, level = 2, className }: StyledSectionHeaderProps) => {\n  const Heading = `h${level}` as keyof JSX.IntrinsicElements\n  return (\n    <Heading\n      className={classNames(\n        'text-2xl font-bold text-desert-green mb-6 flex items-center gap-2',\n        className\n      )}\n    >\n      <div className=\"w-1 h-6 bg-desert-green\" />\n      {title}\n    </Heading>\n  )\n}\n\nexport default StyledSectionHeader\n"
  },
  {
    "path": "admin/inertia/components/StyledSidebar.tsx",
    "content": "import { useMemo, useState } from 'react'\nimport { Dialog, DialogBackdrop, DialogPanel, TransitionChild } from '@headlessui/react'\nimport classNames from '~/lib/classNames'\nimport { IconArrowLeft, IconBug } from '@tabler/icons-react'\nimport { usePage } from '@inertiajs/react'\nimport { UsePageProps } from '../../types/system'\nimport { IconMenu2, IconX } from '@tabler/icons-react'\nimport ThemeToggle from '~/components/ThemeToggle'\nimport DebugInfoModal from './DebugInfoModal'\n\ntype SidebarItem = {\n  name: string\n  href: string\n  icon?: React.ElementType\n  current: boolean\n  target?: string\n}\n\ninterface StyledSidebarProps {\n  title: string\n  items: SidebarItem[]\n}\n\nconst StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {\n  const [sidebarOpen, setSidebarOpen] = useState(false)\n  const [debugModalOpen, setDebugModalOpen] = useState(false)\n  const { appVersion } = usePage().props as unknown as UsePageProps\n\n  const currentPath = useMemo(() => {\n    if (typeof window === 'undefined') return ''\n    return window.location.pathname\n  }, [])\n\n  const ListItem = (item: SidebarItem) => {\n    return (\n      <li key={item.name}>\n        <a\n          href={item.href}\n          target={item.target}\n          className={classNames(\n            item.current\n              ? 'bg-desert-green text-white'\n              : 'text-text-primary hover:bg-desert-green-light hover:text-white',\n            'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold'\n          )}\n        >\n          {item.icon && <item.icon aria-hidden=\"true\" className=\"size-6 shrink-0\" />}\n          {item.name}\n        </a>\n      </li>\n    )\n  }\n\n  const Sidebar = () => {\n    return (\n      <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\">\n        <div className=\"flex h-16 shrink-0 items-center\">\n          <img src=\"/project_nomad_logo.png\" alt=\"Project Nomad Logo\" className=\"h-16 w-16\" />\n          <h1 className=\"ml-3 text-xl font-semibold text-text-primary\">{title}</h1>\n        </div>\n        <nav className=\"flex flex-1 flex-col\">\n          <ul role=\"list\" className=\"flex flex-1 flex-col gap-y-7\">\n            <li>\n              <ul role=\"list\" className=\"-mx-2 space-y-1\">\n                {items.map((item) => (\n                  <ListItem key={item.name} {...item} current={currentPath === item.href} />\n                ))}\n                <li className=\"ml-2 mt-4\">\n                  <a\n                    href=\"/home\"\n                    className=\"flex flex-row items-center gap-x-3 text-desert-green text-sm font-semibold\"\n                  >\n                    <IconArrowLeft aria-hidden=\"true\" className=\"size-6 shrink-0\" />\n                    Back to Home\n                  </a>\n                </li>\n              </ul>\n            </li>\n          </ul>\n        </nav>\n        <div className=\"mb-4 flex flex-col items-center gap-1 text-sm text-text-secondary\">\n          <p>Project N.O.M.A.D. Command Center v{appVersion}</p>\n          <button\n            onClick={() => setDebugModalOpen(true)}\n            className=\"mt-1 text-gray-500 hover:text-desert-green inline-flex items-center gap-1 cursor-pointer\"\n          >\n            <IconBug className=\"size-3.5\" />\n            Debug Info\n          </button>\n          <ThemeToggle />\n        </div>\n      </div>\n    )\n  }\n\n  return (\n    <>\n      <button\n        type=\"button\"\n        className=\"absolute left-4 top-4 z-50 xl:hidden\"\n        onClick={() => setSidebarOpen(true)}\n      >\n        <IconMenu2 aria-hidden=\"true\" className=\"size-8\" />\n      </button>\n      {/* Mobile sidebar */}\n      <Dialog open={sidebarOpen} onClose={setSidebarOpen} className=\"relative z-50 xl:hidden\">\n        <DialogBackdrop\n          transition\n          className=\"fixed inset-0 bg-black/10 transition-opacity duration-300 ease-linear data-[closed]:opacity-0\"\n        />\n\n        <div className=\"fixed inset-0 flex\">\n          <DialogPanel\n            transition\n            className=\"relative mr-16 flex w-full max-w-xs flex-1 transform transition duration-300 ease-in-out data-[closed]:-translate-x-full\"\n          >\n            <TransitionChild>\n              <div className=\"absolute left-full top-0 flex w-16 justify-center pt-5 duration-300 ease-in-out data-[closed]:opacity-0\">\n                <button\n                  type=\"button\"\n                  onClick={() => setSidebarOpen(false)}\n                  className=\"-m-2.5 p-2.5\"\n                >\n                  <span className=\"sr-only\">Close sidebar</span>\n                  <IconX aria-hidden=\"true\" className=\"size-6 text-white\" />\n                </button>\n              </div>\n            </TransitionChild>\n            <Sidebar />\n          </DialogPanel>\n        </div>\n      </Dialog>\n      {/* Desktop sidebar */}\n      <div className=\"hidden xl:fixed xl:inset-y-0 xl:z-50 xl:flex xl:w-72 xl:flex-col\">\n        <Sidebar />\n      </div>\n      <DebugInfoModal open={debugModalOpen} onClose={() => setDebugModalOpen(false)} />\n    </>\n  )\n}\n\nexport default StyledSidebar\n"
  },
  {
    "path": "admin/inertia/components/StyledTable.tsx",
    "content": "import { capitalizeFirstLetter } from '~/lib/util'\nimport classNames from '~/lib/classNames'\nimport LoadingSpinner from '~/components/LoadingSpinner'\nimport React, { RefObject, useState } from 'react'\n\nexport type StyledTableProps<T extends { [key: string]: any }> = {\n  loading?: boolean\n  tableProps?: React.HTMLAttributes<HTMLTableElement>\n  tableRowStyle?: React.CSSProperties\n  tableBodyClassName?: string\n  tableBodyStyle?: React.CSSProperties\n  data?: T[]\n  noDataText?: string\n  onRowClick?: (record: T) => void\n  columns?: {\n    accessor: keyof T\n    title?: React.ReactNode\n    render?: (record: T, index: number) => React.ReactNode\n    className?: string\n  }[]\n  className?: string\n  rowLines?: boolean\n  ref?: RefObject<HTMLDivElement | null>\n  containerProps?: React.HTMLAttributes<HTMLDivElement>\n  compact?: boolean\n  expandable?: {\n    expandedRowRender: (record: T, index: number) => React.ReactNode\n    defaultExpandedRowKeys?: (string | number)[]\n    onExpandedRowsChange?: (expandedKeys: (string | number)[]) => void\n    expandIconColumnIndex?: number\n  }\n}\n\nfunction StyledTable<T extends { [key: string]: any }>({\n  loading = false,\n  tableProps = {},\n  tableRowStyle = {},\n  tableBodyClassName = '',\n  tableBodyStyle = {},\n  data = [],\n  noDataText = 'No records found',\n  onRowClick,\n  columns = [],\n  className = '',\n  ref,\n  containerProps = {},\n  rowLines = true,\n  compact = false,\n  expandable,\n}: StyledTableProps<T>) {\n  const { className: tableClassName, ...restTableProps } = tableProps\n\n  const [expandedRowKeys, setExpandedRowKeys] = useState<(string | number)[]>(\n    expandable?.defaultExpandedRowKeys || []\n  )\n\n  const leftPadding = compact ? 'pl-2' : 'pl-4 sm:pl-6'\n\n  const isRowExpanded = (record: T, index: number) => {\n    const key = record.id ?? index\n    return expandedRowKeys.includes(key)\n  }\n\n  const toggleRowExpansion = (record: T, index: number, event: React.MouseEvent) => {\n    event.stopPropagation()\n    const key = record.id ?? index\n    const newExpandedKeys = expandedRowKeys.includes(key)\n      ? expandedRowKeys.filter((k) => k !== key)\n      : [...expandedRowKeys, key]\n    setExpandedRowKeys(newExpandedKeys)\n    expandable?.onExpandedRowsChange?.(newExpandedKeys)\n  }\n\n  return (\n    <div\n      className={classNames(\n        'w-full overflow-x-auto bg-surface-primary ring-1 ring-border-default sm:mx-0 sm:rounded-lg p-1 shadow-md',\n        className\n      )}\n      ref={ref}\n      {...containerProps}\n    >\n      <table className=\"min-w-full overflow-auto\" {...restTableProps}>\n        <thead className='border-b border-border-subtle '>\n          <tr>\n            {expandable && (\n              <th\n                className={classNames(\n                  'whitespace-nowrap text-left font-semibold text-text-primary w-12',\n                  compact ? `${leftPadding} py-2` : `${leftPadding} py-4  pr-3`\n                )}\n              />\n            )}\n            {columns.map((column, index) => (\n              <th\n                key={index}\n                className={classNames(\n                  'whitespace-nowrap text-left font-semibold text-text-primary',\n                  compact ? `${leftPadding} py-2` : `${leftPadding} py-4  pr-3`\n                )}\n              >\n                {column.title ?? capitalizeFirstLetter(column.accessor.toString())}\n              </th>\n            ))}\n          </tr>\n        </thead>\n        <tbody className={tableBodyClassName} style={tableBodyStyle}>\n          {!loading &&\n            data.length !== 0 &&\n            data.map((record, recordIdx) => {\n              const isExpanded = expandable && isRowExpanded(record, recordIdx)\n              return (\n                <React.Fragment key={record.id || recordIdx}>\n                  <tr\n                    data-index={'index' in record ? record.index : recordIdx}\n                    onClick={() => onRowClick?.(record)}\n                    style={{\n                      ...tableRowStyle,\n                      height: 'height' in record ? record.height : 'auto',\n                      transform:\n                        'translateY' in record ? 'translateY(' + record.transformY + 'px)' : undefined,\n                    }}\n                    className={classNames(\n                      rowLines ? 'border-b border-border-subtle' : '',\n                      onRowClick ? `cursor-pointer hover:bg-surface-secondary ` : ''\n                    )}\n                  >\n                    {expandable && (\n                      <td\n                        className={classNames(\n                          'text-sm whitespace-nowrap text-left w-12',\n                          compact ? `${leftPadding} py-2` : `${leftPadding} py-4 pr-3`\n                        )}\n                        onClick={(e) => toggleRowExpansion(record, recordIdx, e)}\n                      >\n                        <button\n                          className=\"text-text-muted hover:text-text-primary focus:outline-none\"\n                          aria-label={isExpanded ? 'Collapse row' : 'Expand row'}\n                        >\n                          <svg\n                            className={classNames(\n                              'w-5 h-5 transition-transform',\n                              isExpanded ? 'rotate-90' : ''\n                            )}\n                            fill=\"none\"\n                            stroke=\"currentColor\"\n                            viewBox=\"0 0 24 24\"\n                          >\n                            <path\n                              strokeLinecap=\"round\"\n                              strokeLinejoin=\"round\"\n                              strokeWidth={2}\n                              d=\"M9 5l7 7-7 7\"\n                            />\n                          </svg>\n                        </button>\n                      </td>\n                    )}\n                    {columns.map((column, index) => (\n                      <td\n                        key={index}\n                        className={classNames(\n                          'relative text-sm whitespace-nowrap max-w-72 truncate break-words text-left',\n                          column.className || '',\n                          compact ? `${leftPadding} py-2` : `${leftPadding} py-4 pr-3`\n                        )}\n                      >\n                        {column.render\n                          ? column.render(record, index)\n                          : (record[column.accessor] as React.ReactNode)}\n                      </td>\n                    ))}\n                  </tr>\n                  {expandable && isExpanded && (\n                    <tr className=\"bg-surface-secondary\">\n                      <td colSpan={columns.length + 1}>\n                        {expandable.expandedRowRender(record, recordIdx)}\n                      </td>\n                    </tr>\n                  )}\n                </React.Fragment>\n              )\n            })}\n          {!loading && data.length === 0 && (\n            <tr>\n              <td colSpan={columns.length + (expandable ? 1 : 0)} className=\"!text-center py-8 text-text-muted\">\n                {noDataText}\n              </td>\n            </tr>\n          )}\n          {loading && (\n            <tr className=\"!h-16\">\n              <td colSpan={columns.length + (expandable ? 1 : 0)} className=\"!text-center\">\n                <LoadingSpinner fullscreen={false} />\n              </td>\n            </tr>\n          )}\n        </tbody>\n      </table>\n    </div>\n  )\n}\n\nexport default StyledTable\n"
  },
  {
    "path": "admin/inertia/components/ThemeToggle.tsx",
    "content": "import { IconSun, IconMoon } from '@tabler/icons-react'\nimport { useThemeContext } from '~/providers/ThemeProvider'\n\ninterface ThemeToggleProps {\n  compact?: boolean\n}\n\nexport default function ThemeToggle({ compact = false }: ThemeToggleProps) {\n  const { theme, toggleTheme } = useThemeContext()\n  const isDark = theme === 'dark'\n\n  return (\n    <button\n      onClick={toggleTheme}\n      className=\"flex items-center gap-1.5 rounded-md px-2 py-1 text-sm transition-colors\n                 text-desert-stone hover:text-desert-green-darker\"\n      aria-label={isDark ? 'Switch to Day Ops' : 'Switch to Night Ops'}\n      title={isDark ? 'Switch to Day Ops' : 'Switch to Night Ops'}\n    >\n      {isDark ? <IconSun className=\"size-4\" /> : <IconMoon className=\"size-4\" />}\n      {!compact && <span>{isDark ? 'Day Ops' : 'Night Ops'}</span>}\n    </button>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/components/TierSelectionModal.tsx",
    "content": "import { Fragment, useState, useEffect } from 'react'\nimport { Dialog, Transition } from '@headlessui/react'\nimport { IconX, IconCheck, IconInfoCircle } from '@tabler/icons-react'\nimport type { CategoryWithStatus, SpecTier, SpecResource } from '../../types/collections'\nimport { resolveTierResources } from '~/lib/collections'\nimport { formatBytes } from '~/lib/util'\nimport classNames from 'classnames'\nimport DynamicIcon, { DynamicIconName } from './DynamicIcon'\n\ninterface TierSelectionModalProps {\n  isOpen: boolean\n  onClose: () => void\n  category: CategoryWithStatus | null\n  selectedTierSlug?: string | null\n  onSelectTier: (category: CategoryWithStatus, tier: SpecTier) => void\n}\n\nconst TierSelectionModal: React.FC<TierSelectionModalProps> = ({\n  isOpen,\n  onClose,\n  category,\n  selectedTierSlug,\n  onSelectTier,\n}) => {\n  // Local selection state - initialized from prop\n  const [localSelectedSlug, setLocalSelectedSlug] = useState<string | null>(null)\n\n  // Reset local selection when modal opens or category changes\n  useEffect(() => {\n    if (isOpen && category) {\n      setLocalSelectedSlug(selectedTierSlug || null)\n    }\n  }, [isOpen, category, selectedTierSlug])\n\n  if (!category) return null\n\n  // Get all resources for a tier (including inherited resources)\n  const getAllResourcesForTier = (tier: SpecTier): SpecResource[] => {\n    return resolveTierResources(tier, category.tiers)\n  }\n\n  const getTierTotalSize = (tier: SpecTier): number => {\n    return getAllResourcesForTier(tier).reduce((acc, r) => acc + r.size_mb * 1024 * 1024, 0)\n  }\n\n  const handleTierClick = (tier: SpecTier) => {\n    // Toggle selection: if clicking the same tier, deselect it\n    if (localSelectedSlug === tier.slug) {\n      setLocalSelectedSlug(null)\n    } else {\n      setLocalSelectedSlug(tier.slug)\n    }\n  }\n\n  const handleSubmit = () => {\n    if (!localSelectedSlug) return\n\n    const selectedTier = category.tiers.find(t => t.slug === localSelectedSlug)\n    if (selectedTier) {\n      onSelectTier(category, selectedTier)\n    }\n    onClose()\n  }\n\n  return (\n    <Transition appear show={isOpen} as={Fragment}>\n      <Dialog as=\"div\" className=\"relative z-50\" onClose={onClose}>\n        <Transition.Child\n          as={Fragment}\n          enter=\"ease-out duration-300\"\n          enterFrom=\"opacity-0\"\n          enterTo=\"opacity-100\"\n          leave=\"ease-in duration-200\"\n          leaveFrom=\"opacity-100\"\n          leaveTo=\"opacity-0\"\n        >\n          <div className=\"fixed inset-0 bg-black/50\" />\n        </Transition.Child>\n\n        <div className=\"fixed inset-0 overflow-y-auto\">\n          <div className=\"flex min-h-full items-center justify-center p-4\">\n            <Transition.Child\n              as={Fragment}\n              enter=\"ease-out duration-300\"\n              enterFrom=\"opacity-0 scale-95\"\n              enterTo=\"opacity-100 scale-100\"\n              leave=\"ease-in duration-200\"\n              leaveFrom=\"opacity-100 scale-100\"\n              leaveTo=\"opacity-0 scale-95\"\n            >\n              <Dialog.Panel className=\"w-full max-w-4xl transform overflow-hidden rounded-lg bg-surface-primary shadow-xl transition-all\">\n                {/* Header */}\n                <div className=\"bg-desert-green px-6 py-4\">\n                  <div className=\"flex items-center justify-between\">\n                    <div className=\"flex items-center\">\n                      <DynamicIcon\n                        icon={category.icon as DynamicIconName}\n                        className=\"w-8 h-8 text-white mr-3\"\n                      />\n                      <div>\n                        <Dialog.Title className=\"text-xl font-semibold text-white\">\n                          {category.name}\n                        </Dialog.Title>\n                        <p className=\"text-sm text-text-muted\">{category.description}</p>\n                      </div>\n                    </div>\n                    <button\n                      onClick={onClose}\n                      className=\"text-white/70 hover:text-white transition-colors\"\n                    >\n                      <IconX size={24} />\n                    </button>\n                  </div>\n                </div>\n\n                {/* Content */}\n                <div className=\"p-6\">\n                  <p className=\"text-text-secondary mb-6\">\n                    Select a tier based on your storage capacity and needs. Higher tiers include all content from lower tiers.\n                  </p>\n\n                  <div className=\"space-y-4\">\n                    {category.tiers.map((tier) => {\n                      const totalSize = getTierTotalSize(tier)\n                      const isSelected = localSelectedSlug === tier.slug\n                      const includedTierName = tier.includesTier\n                        ? category.tiers.find(t => t.slug === tier.includesTier)?.name\n                        : null\n                      // Only show this tier's own resources (not inherited)\n                      const ownResources = tier.resources\n                      const ownResourceCount = ownResources.length\n\n                      return (\n                        <div\n                          key={tier.slug}\n                          onClick={() => handleTierClick(tier)}\n                          className={classNames(\n                            'border-2 rounded-lg p-5 cursor-pointer transition-all',\n                            isSelected\n                              ? 'border-desert-green bg-desert-green/5 shadow-md'\n                              : 'border-border-subtle hover:border-desert-green/50 hover:shadow-sm'\n                          )}\n                        >\n                          <div className=\"flex items-start justify-between\">\n                            <div className=\"flex-1\">\n                              <div className=\"flex items-center gap-2 mb-1\">\n                                <h3 className=\"text-lg font-semibold text-text-primary\">\n                                  {tier.name}\n                                </h3>\n                                {includedTierName && (\n                                  <span className=\"text-xs text-text-muted\">\n                                    (includes {includedTierName})\n                                  </span>\n                                )}\n                              </div>\n                              <p className=\"text-text-secondary text-sm mb-3\">{tier.description}</p>\n\n                              {/* Resources preview - only show this tier's own resources */}\n                              <div className=\"bg-surface-secondary rounded p-3\">\n                                <p className=\"text-xs text-text-muted mb-2 font-medium\">\n                                  {includedTierName ? (\n                                    <>\n                                      {ownResourceCount} additional {ownResourceCount === 1 ? 'resource' : 'resources'}\n                                      <span className=\"text-text-muted\"> (plus everything in {includedTierName})</span>\n                                    </>\n                                  ) : (\n                                    <>{ownResourceCount} {ownResourceCount === 1 ? 'resource' : 'resources'} included</>\n                                  )}\n                                </p>\n                                <div className=\"grid grid-cols-1 md:grid-cols-2 gap-2\">\n                                  {ownResources.map((resource, idx) => (\n                                    <div key={idx} className=\"flex items-start text-sm\">\n                                      <IconCheck size={14} className=\"text-desert-green mr-1.5 mt-0.5 flex-shrink-0\" />\n                                      <div>\n                                        <span className=\"text-text-primary\">{resource.title}</span>\n                                        <span className=\"text-text-muted text-xs ml-1\">\n                                          ({formatBytes(resource.size_mb * 1024 * 1024, 0)})\n                                        </span>\n                                      </div>\n                                    </div>\n                                  ))}\n                                </div>\n                              </div>\n                            </div>\n\n                            <div className=\"ml-4 text-right flex-shrink-0\">\n                              <div className=\"text-lg font-semibold text-text-primary\">\n                                {formatBytes(totalSize, 1)}\n                              </div>\n                              <div className={classNames(\n                                'w-6 h-6 rounded-full border-2 flex items-center justify-center mt-2 ml-auto',\n                                isSelected\n                                  ? 'border-desert-green bg-desert-green'\n                                  : 'border-border-default'\n                              )}>\n                                {isSelected && <IconCheck size={16} className=\"text-white\" />}\n                              </div>\n                            </div>\n                          </div>\n                        </div>\n                      )\n                    })}\n                  </div>\n\n                  {/* Info note */}\n                  <div className=\"mt-6 flex items-start gap-2 text-sm text-text-muted bg-blue-50 p-3 rounded\">\n                    <IconInfoCircle size={18} className=\"text-blue-500 flex-shrink-0 mt-0.5\" />\n                    <p>\n                      You can change your selection at any time. Click Submit to confirm your choice.\n                    </p>\n                  </div>\n                </div>\n\n                {/* Footer */}\n                <div className=\"bg-surface-secondary px-6 py-4 flex justify-end gap-3\">\n                  <button\n                    onClick={handleSubmit}\n                    disabled={!localSelectedSlug}\n                    className={classNames(\n                      'px-4 py-2 rounded-md font-medium transition-colors',\n                      localSelectedSlug\n                        ? 'bg-desert-green text-white hover:bg-desert-green/90'\n                        : 'bg-border-default text-text-muted cursor-not-allowed'\n                    )}\n                  >\n                    Submit\n                  </button>\n                </div>\n              </Dialog.Panel>\n            </Transition.Child>\n          </div>\n        </div>\n      </Dialog>\n    </Transition>\n  )\n}\n\nexport default TierSelectionModal\n"
  },
  {
    "path": "admin/inertia/components/UpdateServiceModal.tsx",
    "content": "import { useState } from \"react\"\nimport { ServiceSlim } from \"../../types/services\"\nimport StyledModal from \"./StyledModal\"\nimport { IconArrowUp } from \"@tabler/icons-react\"\nimport api from \"~/lib/api\"\n\n\ninterface UpdateServiceModalProps {\n    record: ServiceSlim\n    currentTag: string\n    latestVersion: string\n    onCancel: () => void\n    onUpdate: (version: string) => void\n    showError: (msg: string) => void\n}\n\nexport default function UpdateServiceModal({\n    record,\n    currentTag,\n    latestVersion,\n    onCancel,\n    onUpdate,\n    showError,\n}: UpdateServiceModalProps) {\n    const [selectedVersion, setSelectedVersion] = useState(latestVersion)\n    const [showAdvanced, setShowAdvanced] = useState(false)\n    const [versions, setVersions] = useState<Array<{ tag: string; isLatest: boolean; releaseUrl?: string }>>([])\n    const [loadingVersions, setLoadingVersions] = useState(false)\n\n    async function loadVersions() {\n        if (versions.length > 0) return\n        setLoadingVersions(true)\n        try {\n            const result = await api.getAvailableVersions(record.service_name)\n            if (result?.versions) {\n                setVersions(result.versions)\n            }\n        } catch (error) {\n            showError('Failed to load available versions')\n        } finally {\n            setLoadingVersions(false)\n        }\n    }\n\n    function handleToggleAdvanced() {\n        const next = !showAdvanced\n        setShowAdvanced(next)\n        if (next) loadVersions()\n    }\n\n    return (\n        <StyledModal\n            title=\"Update Service\"\n            onConfirm={() => onUpdate(selectedVersion)}\n            onCancel={onCancel}\n            open={true}\n            confirmText=\"Update\"\n            cancelText=\"Cancel\"\n            confirmVariant=\"primary\"\n            icon={<IconArrowUp className=\"h-12 w-12 text-desert-green\" />}\n        >\n            <div className=\"space-y-4\">\n                <p className=\"text-text-primary\">\n                    Update <strong>{record.friendly_name || record.service_name}</strong> from{' '}\n                    <code className=\"bg-surface-secondary px-1.5 py-0.5 rounded text-sm\">{currentTag}</code> to{' '}\n                    <code className=\"bg-surface-secondary px-1.5 py-0.5 rounded text-sm\">{selectedVersion}</code>?\n                </p>\n                <p className=\"text-sm text-text-muted\">\n                    Your data and configuration will be preserved during the update.\n                    {versions.find((v) => v.tag === selectedVersion)?.releaseUrl && (\n                        <>\n                            {' '}\n                            <a\n                                href={versions.find((v) => v.tag === selectedVersion)!.releaseUrl}\n                                target=\"_blank\"\n                                rel=\"noopener noreferrer\"\n                                className=\"text-desert-green hover:underline\"\n                            >\n                                View release notes\n                            </a>\n                        </>\n                    )}\n                </p>\n\n                <div>\n                    <button\n                        type=\"button\"\n                        onClick={handleToggleAdvanced}\n                        className=\"text-sm text-desert-green hover:underline font-medium\"\n                    >\n                        {showAdvanced ? 'Hide' : 'Show'} available versions\n                    </button>\n\n                    {showAdvanced && (\n                        <>\n                            <div className=\"mt-3 max-h-48 overflow-y-auto border rounded-lg divide-y\">\n                                {loadingVersions ? (\n                                    <div className=\"p-4 text-center text-text-muted text-sm\">Loading versions...</div>\n                                ) : versions.length === 0 ? (\n                                    <div className=\"p-4 text-center text-text-muted text-sm\">No other versions available</div>\n                                ) : (\n                                    versions.map((v) => (\n                                        <label\n                                            key={v.tag}\n                                            className=\"flex items-center gap-3 px-4 py-2.5 hover:bg-surface-secondary cursor-pointer\"\n                                        >\n                                            <input\n                                                type=\"radio\"\n                                                name=\"version\"\n                                                value={v.tag}\n                                                checked={selectedVersion === v.tag}\n                                                onChange={() => setSelectedVersion(v.tag)}\n                                                className=\"text-desert-green focus:ring-desert-green\"\n                                            />\n                                            <span className=\"text-sm font-medium text-text-primary\">{v.tag}</span>\n                                            {v.isLatest && (\n                                                <span className=\"text-xs bg-desert-green/10 text-desert-green px-2 py-0.5 rounded-full\">\n                                                    Latest\n                                                </span>\n                                            )}\n                                            {v.releaseUrl && (\n                                                <a\n                                                    href={v.releaseUrl}\n                                                    target=\"_blank\"\n                                                    rel=\"noopener noreferrer\"\n                                                    className=\"ml-auto text-xs text-desert-green hover:underline\"\n                                                    onClick={(e) => e.stopPropagation()}\n                                                >\n                                                    Release notes\n                                                </a>\n                                            )}\n                                        </label>\n                                    ))\n                                )}\n                            </div>\n                            <p className=\"mt-2 text-sm text-text-muted\">\n                                It's not recommended to upgrade to a new major version (e.g. 1.8.2 &rarr; 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.\n                            </p>\n                        </>\n                    )}\n                </div>\n            </div>\n        </StyledModal>\n    )\n}\n"
  },
  {
    "path": "admin/inertia/components/WikipediaSelector.tsx",
    "content": "import { formatBytes } from '~/lib/util'\nimport { WikipediaOption, WikipediaCurrentSelection } from '../../types/downloads'\nimport classNames from 'classnames'\nimport { IconCheck, IconDownload, IconWorld, IconAlertTriangle } from '@tabler/icons-react'\nimport StyledButton from './StyledButton'\nimport LoadingSpinner from './LoadingSpinner'\n\nexport interface WikipediaSelectorProps {\n  options: WikipediaOption[]\n  currentSelection: WikipediaCurrentSelection | null\n  selectedOptionId: string | null // for wizard (pending selection)\n  onSelect: (optionId: string) => void\n  disabled?: boolean\n  showSubmitButton?: boolean // true for Content Explorer, false for wizard\n  onSubmit?: () => void\n  isSubmitting?: boolean\n}\n\nconst WikipediaSelector: React.FC<WikipediaSelectorProps> = ({\n  options,\n  currentSelection,\n  selectedOptionId,\n  onSelect,\n  disabled = false,\n  showSubmitButton = false,\n  onSubmit,\n  isSubmitting = false,\n}) => {\n  // Determine which option to highlight\n  const highlightedOptionId = selectedOptionId ?? currentSelection?.optionId ?? null\n\n  // Check if current selection is downloading or failed\n  const isDownloading = currentSelection?.status === 'downloading'\n  const isFailed = currentSelection?.status === 'failed'\n\n  return (\n    <div className=\"w-full\">\n      {/* Header with Wikipedia branding */}\n      <div className=\"flex items-center gap-3 mb-4\">\n        <div className=\"w-10 h-10 rounded-full bg-white flex items-center justify-center shadow-sm\">\n          <IconWorld className=\"w-6 h-6 text-text-primary\" />\n        </div>\n        <div>\n          <h3 className=\"text-xl font-semibold text-text-primary\">Wikipedia</h3>\n          <p className=\"text-sm text-text-muted\">Select your preferred Wikipedia package</p>\n        </div>\n      </div>\n\n      {/* Downloading status message */}\n      {isDownloading && (\n        <div className=\"mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-center gap-2\">\n          <LoadingSpinner fullscreen={false} iconOnly className=\"size-4\" />\n          <span className=\"text-sm text-blue-700\">\n            Downloading Wikipedia... This may take a while for larger packages.\n          </span>\n        </div>\n      )}\n\n      {/* Failed status message */}\n      {isFailed && (\n        <div className=\"mb-4 p-3 bg-red-50 border border-red-200 rounded-lg flex items-center justify-between\">\n          <div className=\"flex items-center gap-2\">\n            <IconAlertTriangle className=\"w-5 h-5 text-red-600 flex-shrink-0\" />\n            <span className=\"text-sm text-red-700\">\n              Wikipedia download failed. Select a package and try again.\n            </span>\n          </div>\n        </div>\n      )}\n\n      {/* Options grid */}\n      <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4\">\n        {options.map((option) => {\n          const isSelected = highlightedOptionId === option.id\n          const isInstalled =\n            currentSelection?.optionId === option.id && currentSelection?.status === 'installed'\n          const isCurrentDownloading =\n            currentSelection?.optionId === option.id && currentSelection?.status === 'downloading'\n          const isCurrentFailed =\n            currentSelection?.optionId === option.id && currentSelection?.status === 'failed'\n          const isPending = selectedOptionId === option.id && selectedOptionId !== currentSelection?.optionId\n\n          return (\n            <div\n              key={option.id}\n              onClick={() => !disabled && !isCurrentDownloading && onSelect(option.id)}\n              className={classNames(\n                'relative p-4 rounded-lg border-2 transition-all',\n                disabled || isCurrentDownloading\n                  ? 'opacity-60 cursor-not-allowed'\n                  : 'cursor-pointer hover:shadow-md',\n                isInstalled\n                  ? 'border-desert-green bg-desert-green/10'\n                  : isSelected\n                    ? 'border-lime-500 bg-lime-50'\n                    : 'border-border-subtle bg-surface-primary hover:border-border-default'\n              )}\n            >\n              {/* Status badges */}\n              <div className=\"absolute top-2 right-2 flex gap-1\">\n                {isInstalled && (\n                  <span className=\"text-xs bg-desert-green text-white px-2 py-0.5 rounded-full flex items-center gap-1\">\n                    <IconCheck size={12} />\n                    Installed\n                  </span>\n                )}\n                {isPending && !isInstalled && (\n                  <span className=\"text-xs bg-lime-500 text-white px-2 py-0.5 rounded-full\">\n                    Selected\n                  </span>\n                )}\n                {isCurrentDownloading && (\n                  <span className=\"text-xs bg-blue-500 text-white px-2 py-0.5 rounded-full flex items-center gap-1\">\n                    <IconDownload size={12} />\n                    Downloading\n                  </span>\n                )}\n                {isCurrentFailed && (\n                  <span className=\"text-xs bg-red-500 text-white px-2 py-0.5 rounded-full flex items-center gap-1\">\n                    <IconAlertTriangle size={12} />\n                    Failed\n                  </span>\n                )}\n              </div>\n\n              {/* Option content */}\n              <div className=\"pr-16 flex flex-col h-full\">\n                <h4 className=\"text-lg font-semibold text-text-primary mb-1\">{option.name}</h4>\n                <p className=\"text-sm text-text-secondary mb-3 flex-grow\">{option.description}</p>\n                <div className=\"flex items-center gap-3\">\n                  {/* Radio indicator */}\n                  <div\n                    className={classNames(\n                      'w-5 h-5 rounded-full border-2 flex items-center justify-center transition-all flex-shrink-0',\n                      isSelected\n                        ? isInstalled\n                          ? 'border-desert-green bg-desert-green'\n                          : 'border-lime-500 bg-lime-500'\n                        : 'border-border-default'\n                    )}\n                  >\n                    {isSelected && <IconCheck size={12} className=\"text-white\" />}\n                  </div>\n                  <span\n                    className={classNames(\n                      'text-sm font-medium px-2 py-1 rounded',\n                      option.size_mb === 0 ? 'bg-surface-secondary text-text-muted' : 'bg-surface-secondary text-text-secondary'\n                    )}\n                  >\n                    {option.size_mb === 0 ? 'No download' : formatBytes(option.size_mb * 1024 * 1024, 1)}\n                  </span>\n                </div>\n              </div>\n            </div>\n          )\n        })}\n      </div>\n\n      {/* Submit button for Content Explorer mode */}\n      {showSubmitButton && selectedOptionId && (selectedOptionId !== currentSelection?.optionId || isFailed) && (\n        <div className=\"mt-4 flex justify-end\">\n          <StyledButton\n            variant=\"primary\"\n            onClick={onSubmit}\n            disabled={isSubmitting || disabled}\n            loading={isSubmitting}\n            icon=\"IconDownload\"\n          >\n            {selectedOptionId === 'none' ? 'Remove Wikipedia' : 'Download Selected'}\n          </StyledButton>\n        </div>\n      )}\n    </div>\n  )\n}\n\nexport default WikipediaSelector\n"
  },
  {
    "path": "admin/inertia/components/chat/ChatAssistantAvatar.tsx",
    "content": "import { IconWand } from \"@tabler/icons-react\";\n\nexport default function ChatAssistantAvatar() {\n  return (\n    <div className=\"flex-shrink-0\">\n      <div className=\"h-8 w-8 rounded-full bg-desert-green flex items-center justify-center\">\n        <IconWand className=\"h-5 w-5 text-white\" />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/components/chat/ChatButton.tsx",
    "content": "import { IconMessages } from '@tabler/icons-react'\n\ninterface ChatButtonProps {\n  onClick: () => void\n}\n\nexport default function ChatButton({ onClick }: ChatButtonProps) {\n  return (\n    <button\n      onClick={onClick}\n      className=\"fixed bottom-6 right-6 z-40 p-4 bg-desert-green text-white rounded-full shadow-lg hover:bg-desert-green/90 transition-all duration-200 hover:scale-110 focus:outline-none focus:ring-2 focus:ring-desert-green focus:ring-offset-2 cursor-pointer\"\n      aria-label=\"Open chat\"\n    >\n      <IconMessages className=\"h-6 w-6\" />\n    </button>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/components/chat/ChatInterface.tsx",
    "content": "import { IconSend, IconWand } from '@tabler/icons-react'\nimport { useState, useRef, useEffect } from 'react'\nimport classNames from '~/lib/classNames'\nimport { ChatMessage } from '../../../types/chat'\nimport ChatMessageBubble from './ChatMessageBubble'\nimport ChatAssistantAvatar from './ChatAssistantAvatar'\nimport BouncingDots from '../BouncingDots'\nimport StyledModal from '../StyledModal'\nimport api from '~/lib/api'\nimport { DEFAULT_QUERY_REWRITE_MODEL } from '../../../constants/ollama'\nimport { useNotifications } from '~/context/NotificationContext'\nimport { usePage } from '@inertiajs/react'\n\ninterface ChatInterfaceProps {\n  messages: ChatMessage[]\n  onSendMessage: (message: string) => void\n  isLoading?: boolean\n  chatSuggestions?: string[]\n  chatSuggestionsEnabled?: boolean\n  chatSuggestionsLoading?: boolean\n  rewriteModelAvailable?: boolean\n}\n\nexport default function ChatInterface({\n  messages,\n  onSendMessage,\n  isLoading = false,\n  chatSuggestions = [],\n  chatSuggestionsEnabled = false,\n  chatSuggestionsLoading = false,\n  rewriteModelAvailable = false\n}: ChatInterfaceProps) {\n  const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props\n  const { addNotification } = useNotifications()\n  const [input, setInput] = useState('')\n  const [downloadDialogOpen, setDownloadDialogOpen] = useState(false)\n  const [isDownloading, setIsDownloading] = useState(false)\n  const messagesEndRef = useRef<HTMLDivElement>(null)\n  const textareaRef = useRef<HTMLTextAreaElement>(null)\n\n  const handleDownloadModel = async () => {\n    setIsDownloading(true)\n    try {\n      await api.downloadModel(DEFAULT_QUERY_REWRITE_MODEL)\n      addNotification({ type: 'success', message: 'Model download queued' })\n    } catch (error) {\n      addNotification({ type: 'error', message: 'Failed to queue model download' })\n    } finally {\n      setIsDownloading(false)\n      setDownloadDialogOpen(false)\n    }\n  }\n\n  const scrollToBottom = () => {\n    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })\n  }\n\n  useEffect(() => {\n    scrollToBottom()\n  }, [messages])\n\n  const handleSubmit = (e: React.FormEvent) => {\n    e.preventDefault()\n    if (input.trim() && !isLoading) {\n      onSendMessage(input.trim())\n      setInput('')\n      if (textareaRef.current) {\n        textareaRef.current.style.height = 'auto'\n      }\n    }\n  }\n\n  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {\n    if (e.key === 'Enter' && !e.shiftKey) {\n      e.preventDefault()\n      handleSubmit(e)\n    }\n  }\n\n  const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {\n    setInput(e.target.value)\n    // Auto-resize textarea\n    e.target.style.height = 'auto'\n    e.target.style.height = `${Math.min(e.target.scrollHeight, 200)}px`\n  }\n\n  return (\n    <div className=\"flex-1 flex flex-col min-h-0 bg-surface-primary shadow-sm\">\n      <div className=\"flex-1 overflow-y-auto px-6 py-4 space-y-6\">\n        {messages.length === 0 ? (\n          <div className=\"h-full flex items-center justify-center\">\n            <div className=\"text-center max-w-md\">\n              <IconWand className=\"h-16 w-16 text-desert-green mx-auto mb-4 opacity-50\" />\n              <h3 className=\"text-lg font-medium text-text-primary mb-2\">Start a conversation</h3>\n              <p className=\"text-text-muted text-sm\">\n                Interact with your installed language models directly in the Command Center.\n              </p>\n              {chatSuggestionsEnabled && chatSuggestions && chatSuggestions.length > 0 && !chatSuggestionsLoading && (\n                <div className=\"mt-8\">\n                  <h4 className=\"text-sm font-medium text-text-secondary mb-2\">Suggestions:</h4>\n                  <div className=\"flex flex-col gap-2\">\n                    {chatSuggestions.map((suggestion, index) => (\n                      <button\n                        key={index}\n                        onClick={() => {\n                          setInput(suggestion)\n                          // Focus the textarea after setting input\n                          setTimeout(() => {\n                            textareaRef.current?.focus()\n                          }, 0)\n                        }}\n                        className=\"px-4 py-2 bg-surface-secondary hover:bg-surface-secondary rounded-lg text-sm text-text-primary transition-colors\"\n                      >\n                        {suggestion}\n                      </button>\n                    ))}\n                  </div>\n                </div>\n              )}\n              {/* Display bouncing dots while loading suggestions */}\n              {chatSuggestionsEnabled && chatSuggestionsLoading && <BouncingDots text=\"Thinking\" containerClassName=\"mt-8\" />}\n              {!chatSuggestionsEnabled && (\n                <div className=\"mt-8 text-sm text-text-muted\">\n                  Need some inspiration? Enable chat suggestions in settings to get started with example prompts.\n                </div>\n              )}\n            </div>\n          </div>\n        ) : (\n          <>\n            {messages.map((message) => (\n              <div\n                key={message.id}\n                className={classNames(\n                  'flex gap-4',\n                  message.role === 'user' ? 'justify-end' : 'justify-start'\n                )}\n              >\n                {message.role === 'assistant' && <ChatAssistantAvatar />}\n                <ChatMessageBubble message={message} />\n              </div>\n            ))}\n            {/* Loading/thinking indicator */}\n            {isLoading && (\n              <div className=\"flex gap-4 justify-start\">\n                <ChatAssistantAvatar />\n                <div className=\"max-w-[70%] rounded-lg px-4 py-3 bg-surface-secondary text-text-primary\">\n                  <BouncingDots text=\"Thinking\" />\n                </div>\n              </div>\n            )}\n\n            <div ref={messagesEndRef} />\n          </>\n        )}\n      </div>\n      <div className=\"border-t border-border-subtle bg-surface-primary px-6 py-4 flex-shrink-0 min-h-[90px]\">\n        <form onSubmit={handleSubmit} className=\"flex gap-3 items-end\">\n          <div className=\"flex-1 relative\">\n            <textarea\n              ref={textareaRef}\n              value={input}\n              onChange={handleInput}\n              onKeyDown={handleKeyDown}\n              placeholder={`Type your message to ${aiAssistantName}... (Shift+Enter for new line)`}\n              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\"\n              rows={1}\n              disabled={isLoading}\n              style={{ maxHeight: '200px' }}\n            />\n          </div>\n          <button\n            type=\"submit\"\n            disabled={!input.trim() || isLoading}\n            className={classNames(\n              'p-3 rounded-lg transition-all duration-200 flex-shrink-0 mb-2',\n              !input.trim() || isLoading\n                ? 'bg-border-default text-text-muted cursor-not-allowed'\n                : 'bg-desert-green text-white hover:bg-desert-green/90 hover:scale-105'\n            )}\n          >\n            {isLoading ? (\n              <div className=\"h-6 w-6 border-2 border-white border-t-transparent rounded-full animate-spin\" />\n            ) : (\n              <IconSend className=\"h-6 w-6\" />\n            )}\n          </button>\n        </form>\n        {!rewriteModelAvailable && (\n          <div className=\"text-sm text-text-muted mt-2\">\n            The {DEFAULT_QUERY_REWRITE_MODEL} model is not installed. Consider{' '}\n            <button\n              onClick={() => setDownloadDialogOpen(true)}\n              className=\"text-desert-green underline hover:text-desert-green/80 cursor-pointer\"\n            >\n              downloading it\n            </button>{' '}\n            for improved retrieval-augmented generation (RAG) performance.\n          </div>\n        )}\n        <StyledModal\n          open={downloadDialogOpen}\n          title={`Download ${DEFAULT_QUERY_REWRITE_MODEL}?`}\n          confirmText=\"Download\"\n          cancelText=\"Cancel\"\n          confirmIcon='IconDownload'\n          confirmVariant='primary'\n          confirmLoading={isDownloading}\n          onConfirm={handleDownloadModel}\n          onCancel={() => setDownloadDialogOpen(false)}\n          onClose={() => setDownloadDialogOpen(false)}\n        >\n          <p className=\"text-text-primary\">\n            This will dispatch a background download job for{' '}\n            <span className=\"font-mono font-medium\">{DEFAULT_QUERY_REWRITE_MODEL}</span> and may take some time to complete. The model\n            will be used to rewrite queries for improved RAG retrieval performance.\n          </p>\n        </StyledModal>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/components/chat/ChatMessageBubble.tsx",
    "content": "import classNames from '~/lib/classNames'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { ChatMessage } from '../../../types/chat'\n\nexport interface ChatMessageBubbleProps {\n  message: ChatMessage\n}\n\nexport default function ChatMessageBubble({ message }: ChatMessageBubbleProps) {\n  return (\n    <div\n      className={classNames(\n        'max-w-[70%] rounded-lg px-4 py-3',\n        message.role === 'user' ? 'bg-desert-green text-white' : 'bg-surface-secondary text-text-primary'\n      )}\n    >\n      {message.isThinking && message.thinking && (\n        <div className=\"mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs\">\n          <div className=\"mb-1 flex items-center gap-1.5 font-medium text-amber-700\">\n            <span>Reasoning</span>\n            <span className=\"h-1.5 w-1.5 rounded-full bg-amber-500 animate-pulse inline-block\" />\n          </div>\n          <div className=\"prose prose-xs max-w-none text-amber-900/80 max-h-32 overflow-y-auto\">\n            <ReactMarkdown remarkPlugins={[remarkGfm]}>{message.thinking}</ReactMarkdown>\n          </div>\n        </div>\n      )}\n      {!message.isThinking && message.thinking && (\n        <details className=\"mb-3 rounded border border-border-subtle bg-surface-secondary text-xs\">\n          <summary className=\"cursor-pointer px-3 py-2 font-medium text-text-muted hover:text-text-primary select-none\">\n            {message.thinkingDuration !== undefined\n              ? `Thought for ${message.thinkingDuration}s`\n              : 'Reasoning'}\n          </summary>\n          <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\">\n            <ReactMarkdown remarkPlugins={[remarkGfm]}>{message.thinking}</ReactMarkdown>\n          </div>\n        </details>\n      )}\n      <div\n        className={classNames(\n          'break-words',\n          message.role === 'assistant' ? 'prose prose-sm max-w-none' : 'whitespace-pre-wrap'\n        )}\n      >\n        {message.role === 'assistant' ? (\n          <ReactMarkdown\n            remarkPlugins={[remarkGfm]}\n            components={{\n              code: ({ node, className, children, ...props }: any) => {\n                const isInline = !className?.includes('language-')\n                if (isInline) {\n                  return (\n                    <code\n                      className=\"bg-gray-800 text-gray-100 px-2 py-0.5 rounded font-mono text-sm\"\n                      {...props}\n                    >\n                      {children}\n                    </code>\n                  )\n                }\n                return (\n                  <code\n                    className=\"block bg-gray-800 text-gray-100 p-3 rounded-lg overflow-x-auto font-mono text-sm my-2\"\n                    {...props}\n                  >\n                    {children}\n                  </code>\n                )\n              },\n              p: ({ children }) => <p className=\"mb-2 last:mb-0\">{children}</p>,\n              ul: ({ children }) => <ul className=\"list-disc pl-5 mb-2\">{children}</ul>,\n              ol: ({ children }) => <ol className=\"list-decimal pl-5 mb-2\">{children}</ol>,\n              li: ({ children }) => <li className=\"mb-1\">{children}</li>,\n              h1: ({ children }) => <h1 className=\"text-xl font-bold mb-2\">{children}</h1>,\n              h2: ({ children }) => <h2 className=\"text-lg font-bold mb-2\">{children}</h2>,\n              h3: ({ children }) => <h3 className=\"text-base font-bold mb-2\">{children}</h3>,\n              blockquote: ({ children }) => (\n                <blockquote className=\"border-l-4 border-border-default pl-4 italic my-2\">\n                  {children}\n                </blockquote>\n              ),\n              a: ({ children, href }) => (\n                <a\n                  href={href}\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                  className=\"text-desert-green underline hover:text-desert-green/80\"\n                >\n                  {children}\n                </a>\n              ),\n            }}\n          >\n            {message.content}\n          </ReactMarkdown>\n        ) : (\n          message.content\n        )}\n        {message.isStreaming && (\n          <span className=\"inline-block w-2 h-4 ml-1 bg-current animate-pulse\" />\n        )}\n      </div>\n      <div\n        className={classNames(\n          'text-xs mt-2',\n          message.role === 'user' ? 'text-white/70' : 'text-text-muted'\n        )}\n      >\n        {message.timestamp.toLocaleTimeString([], {\n          hour: '2-digit',\n          minute: '2-digit',\n        })}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/components/chat/ChatModal.tsx",
    "content": "import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react'\nimport Chat from './index'\nimport { useSystemSetting } from '~/hooks/useSystemSetting'\nimport { parseBoolean } from '../../../app/utils/misc'\n\ninterface ChatModalProps {\n  open: boolean\n  onClose: () => void\n}\n\nexport default function ChatModal({ open, onClose }: ChatModalProps) {\n  const settings = useSystemSetting({\n    key: \"chat.suggestionsEnabled\"\n  })\n\n  return (\n    <Dialog open={open} onClose={onClose} className=\"relative z-50\">\n      <DialogBackdrop\n        transition\n        className=\"fixed inset-0 bg-black/30 backdrop-blur-sm transition-opacity data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in\"\n      />\n\n      <div className=\"fixed inset-0 flex items-center justify-center p-4\">\n        <DialogPanel\n          transition\n          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\"\n        >\n          <Chat enabled={open} isInModal onClose={onClose} suggestionsEnabled={parseBoolean(settings.data?.value)} />\n        </DialogPanel>\n      </div>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/components/chat/ChatSidebar.tsx",
    "content": "import classNames from '~/lib/classNames'\nimport StyledButton from '../StyledButton'\nimport { router, usePage } from '@inertiajs/react'\nimport { ChatSession } from '../../../types/chat'\nimport { IconMessage } from '@tabler/icons-react'\nimport { useState } from 'react'\nimport KnowledgeBaseModal from './KnowledgeBaseModal'\n\ninterface ChatSidebarProps {\n  sessions: ChatSession[]\n  activeSessionId: string | null\n  onSessionSelect: (id: string) => void\n  onNewChat: () => void\n  onClearHistory: () => void\n  isInModal?: boolean\n}\n\nexport default function ChatSidebar({\n  sessions,\n  activeSessionId,\n  onSessionSelect,\n  onNewChat,\n  onClearHistory,\n  isInModal = false,\n}: ChatSidebarProps) {\n  const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props\n  const [isKnowledgeBaseModalOpen, setIsKnowledgeBaseModalOpen] = useState(\n    () => new URLSearchParams(window.location.search).get('knowledge_base') === 'true'\n  )\n\n  function handleCloseKnowledgeBase() {\n    setIsKnowledgeBaseModalOpen(false)\n    const params = new URLSearchParams(window.location.search)\n    if (params.has('knowledge_base')) {\n      params.delete('knowledge_base')\n      const newUrl = [window.location.pathname, params.toString()].filter(Boolean).join('?')\n      window.history.replaceState(window.history.state, '', newUrl)\n    }\n  }\n\n  return (\n    <div className=\"w-64 bg-surface-secondary border-r border-border-subtle flex flex-col h-full\">\n      <div className=\"p-4 border-b border-border-subtle h-[75px] flex items-center justify-center\">\n        <StyledButton onClick={onNewChat} icon=\"IconPlus\" variant=\"primary\" fullWidth>\n          New Chat\n        </StyledButton>\n      </div>\n\n      <div className=\"flex-1 overflow-y-auto\">\n        {sessions.length === 0 ? (\n          <div className=\"p-4 text-center text-text-muted text-sm\">No previous chats</div>\n        ) : (\n          <div className=\"p-2 space-y-1\">\n            {sessions.map((session) => (\n              <button\n                key={session.id}\n                onClick={() => onSessionSelect(session.id)}\n                className={classNames(\n                  'w-full text-left px-3 py-2 rounded-lg transition-colors group',\n                  activeSessionId === session.id\n                    ? 'bg-desert-green text-white'\n                    : 'hover:bg-surface-secondary text-text-primary'\n                )}\n              >\n                <div className=\"flex items-start gap-2\">\n                  <IconMessage\n                    className={classNames(\n                      'h-5 w-5 mt-0.5 shrink-0',\n                      activeSessionId === session.id ? 'text-white' : 'text-text-muted'\n                    )}\n                  />\n                  <div className=\"flex-1 min-w-0\">\n                    <div className=\"font-medium text-sm truncate\">{session.title}</div>\n                    {session.lastMessage && (\n                      <div\n                        className={classNames(\n                          'text-xs truncate mt-0.5',\n                          activeSessionId === session.id ? 'text-white/80' : 'text-text-muted'\n                        )}\n                      >\n                        {session.lastMessage}\n                      </div>\n                    )}\n                  </div>\n                </div>\n              </button>\n            ))}\n          </div>\n        )}\n      </div>\n      <div className=\"p-4 flex flex-col items-center justify-center gap-y-2\">\n        <img src=\"/project_nomad_logo.png\" alt=\"Project Nomad Logo\" className=\"h-28 w-28 mb-6\" />\n        <StyledButton\n          onClick={() => {\n            if (isInModal) {\n              window.open('/chat', '_blank')\n            } else {\n              router.visit('/home')\n            }\n          }}\n          icon={isInModal ? 'IconExternalLink' : 'IconHome'}\n          variant=\"outline\"\n          size=\"sm\"\n          fullWidth\n        >\n          {isInModal ? 'Open in New Tab' : 'Back to Home'}\n        </StyledButton>\n        <StyledButton\n          onClick={() => {\n            router.visit('/settings/models')\n          }}\n          icon=\"IconDatabase\"\n          variant=\"primary\"\n          size=\"sm\"\n          fullWidth\n        >\n          Models & Settings\n        </StyledButton>\n        <StyledButton\n          onClick={() => {\n            setIsKnowledgeBaseModalOpen(true)\n          }}\n          icon=\"IconBrain\"\n          variant=\"primary\"\n          size=\"sm\"\n          fullWidth\n        >\n          Knowledge Base\n        </StyledButton>\n        {sessions.length > 0 && (\n          <StyledButton\n            onClick={onClearHistory}\n            icon=\"IconTrash\"\n            variant=\"danger\"\n            size=\"sm\"\n            fullWidth\n          >\n            Clear History\n          </StyledButton>\n        )}\n      </div>\n      {isKnowledgeBaseModalOpen && (\n        <KnowledgeBaseModal aiAssistantName={aiAssistantName} onClose={handleCloseKnowledgeBase} />\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/components/chat/KnowledgeBaseModal.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\nimport { useRef, useState } from 'react'\nimport FileUploader from '~/components/file-uploader'\nimport StyledButton from '~/components/StyledButton'\nimport StyledSectionHeader from '~/components/StyledSectionHeader'\nimport StyledTable from '~/components/StyledTable'\nimport { useNotifications } from '~/context/NotificationContext'\nimport api from '~/lib/api'\nimport { IconX } from '@tabler/icons-react'\nimport { useModals } from '~/context/ModalContext'\nimport StyledModal from '../StyledModal'\nimport ActiveEmbedJobs from '~/components/ActiveEmbedJobs'\n\ninterface KnowledgeBaseModalProps {\n  aiAssistantName?: string\n  onClose: () => void\n}\n\nfunction sourceToDisplayName(source: string): string {\n  const parts = source.split(/[/\\\\]/)\n  return parts[parts.length - 1]\n}\n\nexport default function KnowledgeBaseModal({ aiAssistantName = \"AI Assistant\", onClose }: KnowledgeBaseModalProps) {\n  const { addNotification } = useNotifications()\n  const [files, setFiles] = useState<File[]>([])\n  const [confirmDeleteSource, setConfirmDeleteSource] = useState<string | null>(null)\n  const fileUploaderRef = useRef<React.ComponentRef<typeof FileUploader>>(null)\n  const { openModal, closeModal } = useModals()\n  const queryClient = useQueryClient()\n\n  const { data: storedFiles = [], isLoading: isLoadingFiles } = useQuery({\n    queryKey: ['storedFiles'],\n    queryFn: () => api.getStoredRAGFiles(),\n    select: (data) => data || [],\n  })\n\n  const uploadMutation = useMutation({\n    mutationFn: (file: File) => api.uploadDocument(file),\n    onSuccess: (data) => {\n      addNotification({\n        type: 'success',\n        message: data?.message || 'Document uploaded and queued for processing',\n      })\n      setFiles([])\n      if (fileUploaderRef.current) {\n        fileUploaderRef.current.clear()\n      }\n    },\n    onError: (error: any) => {\n      addNotification({\n        type: 'error',\n        message: error?.message || 'Failed to upload document',\n      })\n    },\n  })\n\n  const deleteMutation = useMutation({\n    mutationFn: (source: string) => api.deleteRAGFile(source),\n    onSuccess: () => {\n      addNotification({ type: 'success', message: 'File removed from knowledge base.' })\n      setConfirmDeleteSource(null)\n      queryClient.invalidateQueries({ queryKey: ['storedFiles'] })\n    },\n    onError: (error: any) => {\n      addNotification({ type: 'error', message: error?.message || 'Failed to delete file.' })\n      setConfirmDeleteSource(null)\n    },\n  })\n\n  const syncMutation = useMutation({\n    mutationFn: () => api.syncRAGStorage(),\n    onSuccess: (data) => {\n      addNotification({\n        type: 'success',\n        message: data?.message || 'Storage synced successfully. If new files were found, they have been queued for processing.',\n      })\n    },\n    onError: (error: any) => {\n      addNotification({\n        type: 'error',\n        message: error?.message || 'Failed to sync storage',\n      })\n    },\n  })\n\n  const handleUpload = () => {\n    if (files.length > 0) {\n      uploadMutation.mutate(files[0])\n    }\n  }\n\n  const handleConfirmSync = () => {\n    openModal(\n      <StyledModal\n        title='Confirm Sync?'\n        onConfirm={() => {\n          syncMutation.mutate()\n          closeModal(\n            \"confirm-sync-modal\"\n          )\n        }}\n        onCancel={() => closeModal(\"confirm-sync-modal\")}\n        open={true}\n        confirmText='Confirm Sync'\n        cancelText='Cancel'\n        confirmVariant='primary'\n      >\n        <p className='text-text-primary'>\n          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.\n          This may cause a temporary increase in resource usage if new files are found and being processed. Are you sure you want to proceed?\n        </p>\n      </StyledModal>,\n      \"confirm-sync-modal\"\n    )\n  }\n\n  return (\n    <div className=\"fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/30 backdrop-blur-sm transition-opacity\">\n      <div className=\"bg-surface-primary rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col\">\n        <div className=\"flex items-center justify-between p-6 border-b border-border-subtle shrink-0\">\n          <h2 className=\"text-2xl font-semibold text-text-primary\">Knowledge Base</h2>\n          <button\n            onClick={onClose}\n            className=\"p-2 hover:bg-surface-secondary rounded-lg transition-colors\"\n          >\n            <IconX className=\"h-6 w-6 text-text-muted\" />\n          </button>\n        </div>\n        <div className=\"overflow-y-auto flex-1 p-6\">\n          <div className=\"bg-surface-primary rounded-lg border shadow-md overflow-hidden\">\n            <div className=\"p-6\">\n              <FileUploader\n                ref={fileUploaderRef}\n                minFiles={1}\n                maxFiles={1}\n                onUpload={(uploadedFiles) => {\n                  setFiles(Array.from(uploadedFiles))\n                }}\n              />\n              <div className=\"flex justify-center gap-4 my-6\">\n                <StyledButton\n                  variant=\"primary\"\n                  size=\"lg\"\n                  icon=\"IconUpload\"\n                  onClick={handleUpload}\n                  disabled={files.length === 0 || uploadMutation.isPending}\n                  loading={uploadMutation.isPending}\n                >\n                  Upload\n                </StyledButton>\n              </div>\n            </div>\n            <div className=\"border-t bg-surface-primary p-6\">\n              <h3 className=\"text-lg font-semibold text-desert-green mb-4\">\n                Why upload documents to your Knowledge Base?\n              </h3>\n              <div className=\"space-y-3\">\n                <div className=\"flex items-start gap-3\">\n                  <div className=\"shrink-0 w-6 h-6 rounded-full bg-desert-green text-white flex items-center justify-center text-sm font-bold\">\n                    1\n                  </div>\n                  <div>\n                    <p className=\"font-medium text-desert-stone-dark\">\n                      {aiAssistantName} Knowledge Base Integration\n                    </p>\n                    <p className=\"text-sm text-desert-stone\">\n                      When you upload documents to your Knowledge Base, NOMAD processes and embeds\n                      the content, making it directly accessible to {aiAssistantName}. This allows{' '}\n                      {aiAssistantName} to reference your specific documents during conversations,\n                      providing more accurate and personalized responses based on your uploaded\n                      data.\n                    </p>\n                  </div>\n                </div>\n                <div className=\"flex items-start gap-3\">\n                  <div className=\"shrink-0 w-6 h-6 rounded-full bg-desert-green text-white flex items-center justify-center text-sm font-bold\">\n                    2\n                  </div>\n                  <div>\n                    <p className=\"font-medium text-desert-stone-dark\">\n                      Enhanced Document Processing with OCR\n                    </p>\n                    <p className=\"text-sm text-desert-stone\">\n                      NOMAD includes built-in Optical Character Recognition (OCR) capabilities,\n                      allowing it to extract text from image-based documents such as scanned PDFs or\n                      photos. This means that even if your documents are not in a standard text\n                      format, NOMAD can still process and embed their content for AI access.\n                    </p>\n                  </div>\n                </div>\n                <div className=\"flex items-start gap-3\">\n                  <div className=\"shrink-0 w-6 h-6 rounded-full bg-desert-green text-white flex items-center justify-center text-sm font-bold\">\n                    3\n                  </div>\n                  <div>\n                    <p className=\"font-medium text-desert-stone-dark\">\n                      Information Library Integration\n                    </p>\n                    <p className=\"text-sm text-desert-stone\">\n                      NOMAD will automatically discover and extract any content you save to your\n                      Information Library (if installed), making it instantly available to {aiAssistantName} without any extra steps.\n                    </p>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </div>\n          <div className=\"my-8\">\n            <ActiveEmbedJobs withHeader={true} />\n          </div>\n\n          <div className=\"my-12\">\n            <div className='flex items-center justify-between mb-6'>\n              <StyledSectionHeader title=\"Stored Knowledge Base Files\" className='!mb-0' />\n              <StyledButton\n                variant=\"secondary\"\n                size=\"md\"\n                icon='IconRefresh'\n                onClick={handleConfirmSync}\n                disabled={syncMutation.isPending || uploadMutation.isPending}\n                loading={syncMutation.isPending || uploadMutation.isPending}\n              >\n                Sync Storage\n              </StyledButton>\n            </div>\n            <StyledTable<{ source: string }>\n              className=\"font-semibold\"\n              rowLines={true}\n              columns={[\n                {\n                  accessor: 'source',\n                  title: 'File Name',\n                  render(record) {\n                    return <span className=\"text-text-primary\">{sourceToDisplayName(record.source)}</span>\n                  },\n                },\n                {\n                  accessor: 'source',\n                  title: '',\n                  render(record) {\n                    const isConfirming = confirmDeleteSource === record.source\n                    const isDeleting = deleteMutation.isPending && confirmDeleteSource === record.source\n                    if (isConfirming) {\n                      return (\n                        <div className=\"flex items-center gap-2 justify-end\">\n                          <span className=\"text-sm text-text-secondary\">Remove from knowledge base?</span>\n                          <StyledButton\n                            variant='danger'\n                            size='sm'\n                            onClick={() => deleteMutation.mutate(record.source)}\n                            disabled={isDeleting}\n                          >\n                            {isDeleting ? 'Deleting…' : 'Confirm'}\n                          </StyledButton>\n                          <StyledButton\n                            variant='ghost'\n                            size='sm'\n                            onClick={() => setConfirmDeleteSource(null)}\n                            disabled={isDeleting}\n                          >\n                            Cancel\n                          </StyledButton>\n                        </div>\n                      )\n                    }\n                    return (\n                      <div className=\"flex justify-end\">\n                        <StyledButton\n                          variant=\"danger\"\n                          size=\"sm\"\n                          icon=\"IconTrash\"\n                          onClick={() => setConfirmDeleteSource(record.source)}\n                          disabled={deleteMutation.isPending}\n                          loading={deleteMutation.isPending && confirmDeleteSource === record.source}\n                        >Delete</StyledButton>\n                      </div>\n                    )\n                  },\n                },\n              ]}\n              data={storedFiles.map((source) => ({ source }))}\n              loading={isLoadingFiles}\n            />\n          </div>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/components/chat/index.tsx",
    "content": "import { useState, useCallback, useEffect, useRef, useMemo } from 'react'\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'\nimport ChatSidebar from './ChatSidebar'\nimport ChatInterface from './ChatInterface'\nimport StyledModal from '../StyledModal'\nimport api from '~/lib/api'\nimport { formatBytes } from '~/lib/util'\nimport { useModals } from '~/context/ModalContext'\nimport { ChatMessage } from '../../../types/chat'\nimport classNames from '~/lib/classNames'\nimport { IconX } from '@tabler/icons-react'\nimport { DEFAULT_QUERY_REWRITE_MODEL } from '../../../constants/ollama'\nimport { useSystemSetting } from '~/hooks/useSystemSetting'\n\ninterface ChatProps {\n  enabled: boolean\n  isInModal?: boolean\n  onClose?: () => void\n  suggestionsEnabled?: boolean\n  streamingEnabled?: boolean\n}\n\nexport default function Chat({\n  enabled,\n  isInModal,\n  onClose,\n  suggestionsEnabled = false,\n  streamingEnabled = true,\n}: ChatProps) {\n  const queryClient = useQueryClient()\n  const { openModal, closeAllModals } = useModals()\n  const [activeSessionId, setActiveSessionId] = useState<string | null>(null)\n  const [messages, setMessages] = useState<ChatMessage[]>([])\n  const [selectedModel, setSelectedModel] = useState<string>('')\n  const [isStreamingResponse, setIsStreamingResponse] = useState(false)\n  const streamAbortRef = useRef<AbortController | null>(null)\n\n  // Fetch all sessions\n  const { data: sessions = [] } = useQuery({\n    queryKey: ['chatSessions'],\n    queryFn: () => api.getChatSessions(),\n    enabled,\n    select: (data) =>\n      data?.map((s) => ({\n        id: s.id,\n        title: s.title,\n        model: s.model || undefined,\n        timestamp: new Date(s.timestamp),\n        lastMessage: s.lastMessage || undefined,\n      })) || [],\n  })\n\n  const activeSession = sessions.find((s) => s.id === activeSessionId)\n\n  const { data: lastModelSetting } = useSystemSetting({ key: 'chat.lastModel', enabled })\n\n  const { data: installedModels = [], isLoading: isLoadingModels } = useQuery({\n    queryKey: ['installedModels'],\n    queryFn: () => api.getInstalledModels(),\n    enabled,\n    select: (data) => data || [],\n  })\n\n  const { data: chatSuggestions, isLoading: chatSuggestionsLoading } = useQuery<string[]>({\n    queryKey: ['chatSuggestions'],\n    queryFn: async ({ signal }) => {\n      const res = await api.getChatSuggestions(signal)\n      return res ?? []\n    },\n    enabled: suggestionsEnabled && !activeSessionId,\n    refetchOnWindowFocus: false,\n    refetchOnMount: false,\n  })\n\n  const rewriteModelAvailable = useMemo(() => {\n    return installedModels.some(model => model.name === DEFAULT_QUERY_REWRITE_MODEL)\n  }, [installedModels])\n\n  const deleteAllSessionsMutation = useMutation({\n    mutationFn: () => api.deleteAllChatSessions(),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['chatSessions'] })\n      setActiveSessionId(null)\n      setMessages([])\n      closeAllModals()\n    },\n  })\n\n  const chatMutation = useMutation({\n    mutationFn: (request: {\n      model: string\n      messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>\n      sessionId?: number\n    }) => api.sendChatMessage({ ...request, stream: false }),\n    onSuccess: async (data) => {\n      if (!data || !activeSessionId) {\n        throw new Error('No response from Ollama')\n      }\n\n      // Add assistant message\n      const assistantMessage: ChatMessage = {\n        id: `msg-${Date.now()}-assistant`,\n        role: 'assistant',\n        content: data.message?.content || 'Sorry, I could not generate a response.',\n        timestamp: new Date(),\n      }\n\n      setMessages((prev) => [...prev, assistantMessage])\n\n      // Refresh sessions to pick up backend-persisted messages and title\n      queryClient.invalidateQueries({ queryKey: ['chatSessions'] })\n      setTimeout(() => queryClient.invalidateQueries({ queryKey: ['chatSessions'] }), 3000)\n    },\n    onError: (error) => {\n      console.error('Error sending message:', error)\n      const errorMessage: ChatMessage = {\n        id: `msg-${Date.now()}-error`,\n        role: 'assistant',\n        content: 'Sorry, there was an error processing your request. Please try again.',\n        timestamp: new Date(),\n      }\n      setMessages((prev) => [...prev, errorMessage])\n    },\n  })\n\n  // Set default model: prefer last used model, fall back to first installed if last model not available\n  useEffect(() => {\n    if (installedModels.length > 0 && !selectedModel) {\n      const lastModel = lastModelSetting?.value as string | undefined\n      if (lastModel && installedModels.some((m) => m.name === lastModel)) {\n        setSelectedModel(lastModel)\n      } else {\n        setSelectedModel(installedModels[0].name)\n      }\n    }\n  }, [installedModels, selectedModel, lastModelSetting])\n\n  // Persist model selection\n  useEffect(() => {\n    if (selectedModel) {\n      api.updateSetting('chat.lastModel', selectedModel)\n    }\n  }, [selectedModel])\n\n  const handleNewChat = useCallback(() => {\n    // Just clear the active session and messages - don't create a session yet\n    setActiveSessionId(null)\n    setMessages([])\n  }, [])\n\n  const handleClearHistory = useCallback(() => {\n    openModal(\n      <StyledModal\n        title=\"Clear All Chat History?\"\n        onConfirm={() => deleteAllSessionsMutation.mutate()}\n        onCancel={closeAllModals}\n        open={true}\n        confirmText=\"Clear All\"\n        cancelText=\"Cancel\"\n        confirmVariant=\"danger\"\n      >\n        <p className=\"text-text-primary\">\n          Are you sure you want to delete all chat sessions? This action cannot be undone and all\n          conversations will be permanently deleted.\n        </p>\n      </StyledModal>,\n      'confirm-clear-history-modal'\n    )\n  }, [openModal, closeAllModals, deleteAllSessionsMutation])\n\n  const handleSessionSelect = useCallback(\n    async (sessionId: string) => {\n      // Cancel any ongoing suggestions fetch\n      queryClient.cancelQueries({ queryKey: ['chatSuggestions'] })\n\n      setActiveSessionId(sessionId)\n      // Load messages for this session\n      const sessionData = await api.getChatSession(sessionId)\n      if (sessionData?.messages) {\n        setMessages(\n          sessionData.messages.map((m) => ({\n            id: m.id,\n            role: m.role,\n            content: m.content,\n            timestamp: new Date(m.timestamp),\n          }))\n        )\n      } else {\n        setMessages([])\n      }\n\n      // Set the model to match the session's model if it exists and is available\n      if (sessionData?.model) {\n        setSelectedModel(sessionData.model)\n      }\n    },\n    [installedModels, queryClient]\n  )\n\n  const handleSendMessage = useCallback(\n    async (content: string) => {\n      let sessionId = activeSessionId\n\n      // Create a new session if none exists\n      if (!sessionId) {\n        const newSession = await api.createChatSession('New Chat', selectedModel)\n        if (newSession) {\n          sessionId = newSession.id\n          setActiveSessionId(sessionId)\n          queryClient.invalidateQueries({ queryKey: ['chatSessions'] })\n        } else {\n          return\n        }\n      }\n\n      // Add user message to UI\n      const userMessage: ChatMessage = {\n        id: `msg-${Date.now()}`,\n        role: 'user',\n        content,\n        timestamp: new Date(),\n      }\n\n      setMessages((prev) => [...prev, userMessage])\n\n      const chatMessages = [\n        ...messages.map((m) => ({ role: m.role, content: m.content })),\n        { role: 'user' as const, content },\n      ]\n\n      if (streamingEnabled !== false) {\n        // Streaming path\n        const abortController = new AbortController()\n        streamAbortRef.current = abortController\n\n        setIsStreamingResponse(true)\n\n        const assistantMsgId = `msg-${Date.now()}-assistant`\n        let isFirstChunk = true\n        let fullContent = ''\n        let thinkingContent = ''\n        let isThinkingPhase = true\n        let thinkingStartTime: number | null = null\n        let thinkingDuration: number | null = null\n\n        try {\n          await api.streamChatMessage(\n            { model: selectedModel || 'llama3.2', messages: chatMessages, stream: true, sessionId: sessionId ? Number(sessionId) : undefined },\n            (chunkContent, chunkThinking, done) => {\n              if (chunkThinking.length > 0 && thinkingStartTime === null) {\n                thinkingStartTime = Date.now()\n              }\n              if (isFirstChunk) {\n                isFirstChunk = false\n                setIsStreamingResponse(false)\n                setMessages((prev) => [\n                  ...prev,\n                  {\n                    id: assistantMsgId,\n                    role: 'assistant',\n                    content: chunkContent,\n                    thinking: chunkThinking,\n                    timestamp: new Date(),\n                    isStreaming: true,\n                    isThinking: chunkThinking.length > 0 && chunkContent.length === 0,\n                    thinkingDuration: undefined,\n                  },\n                ])\n              } else {\n                if (isThinkingPhase && chunkContent.length > 0) {\n                  isThinkingPhase = false\n                  if (thinkingStartTime !== null) {\n                    thinkingDuration = Math.max(1, Math.round((Date.now() - thinkingStartTime) / 1000))\n                  }\n                }\n                setMessages((prev) =>\n                  prev.map((m) =>\n                    m.id === assistantMsgId\n                      ? {\n                        ...m,\n                        content: m.content + chunkContent,\n                        thinking: (m.thinking ?? '') + chunkThinking,\n                        isStreaming: !done,\n                        isThinking: isThinkingPhase,\n                        thinkingDuration: thinkingDuration ?? undefined,\n                      }\n                      : m\n                  )\n                )\n              }\n              fullContent += chunkContent\n              thinkingContent += chunkThinking\n            },\n            abortController.signal\n          )\n        } catch (error: any) {\n          if (error?.name !== 'AbortError') {\n            setMessages((prev) => {\n              const hasAssistantMsg = prev.some((m) => m.id === assistantMsgId)\n              if (hasAssistantMsg) {\n                return prev.map((m) =>\n                  m.id === assistantMsgId ? { ...m, isStreaming: false } : m\n                )\n              }\n              return [\n                ...prev,\n                {\n                  id: assistantMsgId,\n                  role: 'assistant',\n                  content: 'Sorry, there was an error processing your request. Please try again.',\n                  timestamp: new Date(),\n                },\n              ]\n            })\n          }\n        } finally {\n          setIsStreamingResponse(false)\n          streamAbortRef.current = null\n        }\n\n        if (fullContent && sessionId) {\n          // Ensure the streaming cursor is removed\n          setMessages((prev) =>\n            prev.map((m) =>\n              m.id === assistantMsgId ? { ...m, isStreaming: false } : m\n            )\n          )\n\n          // Refresh sessions to pick up backend-persisted messages and title\n          queryClient.invalidateQueries({ queryKey: ['chatSessions'] })\n          setTimeout(() => queryClient.invalidateQueries({ queryKey: ['chatSessions'] }), 3000)\n        }\n      } else {\n        // Non-streaming (legacy) path\n        chatMutation.mutate({\n          model: selectedModel || 'llama3.2',\n          messages: chatMessages,\n          sessionId: sessionId ? Number(sessionId) : undefined,\n        })\n      }\n    },\n    [activeSessionId, messages, selectedModel, chatMutation, queryClient, streamingEnabled]\n  )\n\n  return (\n    <div\n      className={classNames(\n        'flex border border-border-subtle overflow-hidden shadow-sm w-full',\n        isInModal ? 'h-full rounded-lg' : 'h-screen'\n      )}\n    >\n      <ChatSidebar\n        sessions={sessions}\n        activeSessionId={activeSessionId}\n        onSessionSelect={handleSessionSelect}\n        onNewChat={handleNewChat}\n        onClearHistory={handleClearHistory}\n        isInModal={isInModal}\n      />\n      <div className=\"flex-1 flex flex-col min-h-0\">\n        <div className=\"px-6 py-3 border-b border-border-subtle bg-surface-secondary flex items-center justify-between h-[75px] flex-shrink-0\">\n          <h2 className=\"text-lg font-semibold text-text-primary\">\n            {activeSession?.title || 'New Chat'}\n          </h2>\n          <div className=\"flex items-center gap-4\">\n            <div className=\"flex items-center gap-2\">\n              <label htmlFor=\"model-select\" className=\"text-sm text-text-secondary\">\n                Model:\n              </label>\n              {isLoadingModels ? (\n                <div className=\"text-sm text-text-muted\">Loading models...</div>\n              ) : installedModels.length === 0 ? (\n                <div className=\"text-sm text-red-600\">No models installed</div>\n              ) : (\n                <select\n                  id=\"model-select\"\n                  value={selectedModel}\n                  onChange={(e) => setSelectedModel(e.target.value)}\n                  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\"\n                >\n                  {installedModels.map((model) => (\n                    <option key={model.name} value={model.name}>\n                      {model.name} ({formatBytes(model.size)})\n                    </option>\n                  ))}\n                </select>\n              )}\n            </div>\n            {isInModal && (\n              <button\n                onClick={() => {\n                  if (onClose) {\n                    onClose()\n                  }\n                }}\n                className=\"rounded-lg hover:bg-surface-secondary transition-colors\"\n              >\n                <IconX className=\"h-6 w-6 text-text-muted\" />\n              </button>\n            )}\n          </div>\n        </div>\n        <ChatInterface\n          messages={messages}\n          onSendMessage={handleSendMessage}\n          isLoading={isStreamingResponse || chatMutation.isPending}\n          chatSuggestions={chatSuggestions}\n          chatSuggestionsEnabled={suggestionsEnabled}\n          chatSuggestionsLoading={chatSuggestionsLoading}\n          rewriteModelAvailable={rewriteModelAvailable}\n        />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/components/file-uploader/index.css",
    "content": ".uppy-size--md .uppy-Dashboard-AddFiles-title {\n  font-size: 1.15rem !important;\n}"
  },
  {
    "path": "admin/inertia/components/file-uploader/index.tsx",
    "content": "import { forwardRef, useImperativeHandle, useState } from 'react'\nimport Uppy from '@uppy/core'\nimport '@uppy/core/css/style.min.css'\nimport '@uppy/dashboard/css/style.min.css'\nimport { useUppyEvent } from '@uppy/react'\nimport Dashboard from '@uppy/react/dashboard'\nimport classNames from 'classnames'\nimport './index.css' // Custom styles for the uploader\n\ninterface FileUploaderProps {\n  minFiles?: number // minimum number of files required\n  maxFiles?: number\n  maxFileSize?: number // in bytes, e.g., 10485760 for 10MB\n  fileTypes?: string[] // e.g., ['image/*', 'application/pdf']\n  disabled?: boolean\n  onUpload: (files: FileList) => void\n  className?: string\n}\n\nexport interface FileUploaderRef {\n  clear: () => void\n}\n\n/**\n * A drag-and-drop (or click) file upload area with customizations for\n * multiple and maximum numbers of files.\n */\nconst FileUploader = forwardRef<FileUploaderRef, FileUploaderProps>((props, ref) => {\n  const {\n    minFiles = 0,\n    maxFiles = 1,\n    maxFileSize = 10485760, // default to 10MB\n    fileTypes,\n    disabled = false,\n    onUpload,\n    className,\n  } = props\n\n  const [uppy] = useState(() => {\n    const uppy = new Uppy({\n      debug: true,\n      restrictions: {\n        maxFileSize: maxFileSize,\n        minNumberOfFiles: minFiles,\n        maxNumberOfFiles: maxFiles,\n        allowedFileTypes: fileTypes || undefined,\n      },\n    })\n    return uppy\n  })\n\n  useImperativeHandle(ref, () => ({\n    clear: () => {\n      uppy.clear()\n    },\n  }))\n\n  useUppyEvent(uppy, 'state-update', (_, newState) => {\n    const stateFiles = Object.values(newState.files)\n\n    const dataTransfer = new DataTransfer()\n    stateFiles.forEach((file) => {\n      if (file.data) {\n        if (file.data instanceof File) {\n          dataTransfer.items.add(file.data)\n        } else if (file.data instanceof Blob) {\n          const newFile = new File(\n            [file.data],\n            file.name || `${crypto.randomUUID()}.${file.extension}`,\n            {\n              type: file.type,\n              lastModified: new Date().getTime(),\n            }\n          )\n          dataTransfer.items.add(newFile)\n        }\n      }\n    })\n\n    const fileList = dataTransfer.files\n    onUpload(fileList) // Always send new file list even if empty\n  })\n\n  return (\n    <Dashboard\n      uppy={uppy}\n      width={'100%'}\n      height={'250px'}\n      hideUploadButton\n      disabled={disabled}\n      className={classNames(className)}\n    />\n  )\n})\n\nFileUploader.displayName = 'FileUploader'\n\nexport default FileUploader\n"
  },
  {
    "path": "admin/inertia/components/inputs/Input.tsx",
    "content": "import classNames from \"classnames\";\nimport { InputHTMLAttributes } from \"react\";\n\nexport interface InputProps extends InputHTMLAttributes<HTMLInputElement> {\n  name: string;\n  label: string;\n  helpText?: string;\n  className?: string;\n  labelClassName?: string;\n  inputClassName?: string;\n  containerClassName?: string;\n  leftIcon?: React.ReactNode;\n  error?: boolean;\n  required?: boolean;\n}\n\nconst Input: React.FC<InputProps> = ({\n  className,\n  label,\n  name,\n  helpText,\n  labelClassName,\n  inputClassName,\n  containerClassName,\n  leftIcon,\n  error,\n  required,\n  ...props\n}) => {\n  return (\n    <div className={classNames(className)}>\n      <label\n        htmlFor={name}\n        className={classNames(\"block text-base/6 font-medium text-text-primary\", labelClassName)}\n      >\n        {label}{required ? \"*\" : \"\"}\n      </label>\n      {helpText && <p className=\"mt-1 text-sm text-text-muted\">{helpText}</p>}\n      <div className={classNames(\"mt-1.5\", containerClassName)}>\n        <div className=\"relative\">\n          {leftIcon && (\n            <div className=\"absolute left-3 top-1/2 transform -translate-y-1/2\">\n              {leftIcon}\n            </div>\n          )}\n          <input\n            id={name}\n            name={name}\n            placeholder={props.placeholder || label}\n            className={classNames(\n              inputClassName,\n              \"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\",\n              leftIcon ? \"pl-10\" : \"pl-3\",\n              error ? \"!border-red-500 focus:outline-red-500 !bg-red-100\" : \"\"\n            )}\n            {...props}\n          />\n        </div>\n      </div>\n    </div>\n  );\n};\n\nexport default Input;\n"
  },
  {
    "path": "admin/inertia/components/inputs/Switch.tsx",
    "content": "import clsx from 'clsx'\n\ninterface SwitchProps {\n  checked: boolean\n  onChange: (checked: boolean) => void\n  label?: string\n  description?: string\n  disabled?: boolean\n  id?: string\n}\n\nexport default function Switch({\n  checked,\n  onChange,\n  label,\n  description,\n  disabled = false,\n  id,\n}: SwitchProps) {\n  const switchId = id || `switch-${label?.replace(/\\s+/g, '-').toLowerCase()}`\n\n  return (\n    <div className=\"flex items-center justify-between py-2\">\n      {(label || description) && (\n        <div className=\"flex-1\">\n          {label && (\n            <label\n              htmlFor={switchId}\n              className=\"text-base font-medium text-text-primary cursor-pointer\"\n            >\n              {label}\n            </label>\n          )}\n          {description && <p className=\"text-sm text-text-muted mt-1\">{description}</p>}\n        </div>\n      )}\n      <div className=\"flex items-center ml-4\">\n        <button\n          id={switchId}\n          type=\"button\"\n          role=\"switch\"\n          aria-checked={checked}\n          disabled={disabled}\n          onClick={() => !disabled && onChange(!checked)}\n          className={clsx(\n            'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent',\n            'transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-desert-green focus:ring-offset-2',\n            checked ? 'bg-desert-green' : 'bg-border-default',\n            disabled ? 'opacity-50 cursor-not-allowed' : ''\n          )}\n        >\n          <span\n            className={clsx(\n              'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0',\n              'transition duration-200 ease-in-out',\n              checked ? 'translate-x-5' : 'translate-x-0'\n            )}\n          />\n        </button>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/components/layout/BackToHomeHeader.tsx",
    "content": "import { Link } from '@inertiajs/react'\nimport { IconArrowLeft } from '@tabler/icons-react'\nimport classNames from '~/lib/classNames'\n\ninterface BackToHomeHeaderProps {\n  className?: string\n  children?: React.ReactNode\n}\n\nexport default function BackToHomeHeader({ className, children }: BackToHomeHeaderProps) {\n  return (\n    <div className={classNames('flex border-b border-border-subtle p-4', className)}>\n      <div className=\"justify-self-start\">\n        <Link href=\"/home\" className=\"flex items-center\">\n          <IconArrowLeft className=\"mr-2\" size={24} />\n          <p className=\"text-lg text-text-secondary\">Back to Home</p>\n        </Link>\n      </div>\n      <div className=\"flex-grow flex flex-col justify-center\">{children}</div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/components/maps/MapComponent.tsx",
    "content": "import Map, { FullscreenControl, NavigationControl, MapProvider } from 'react-map-gl/maplibre'\nimport maplibregl from 'maplibre-gl'\nimport 'maplibre-gl/dist/maplibre-gl.css'\nimport { Protocol } from 'pmtiles'\nimport { useEffect } from 'react'\n\nexport default function MapComponent() {\n\n  // Add the PMTiles protocol to maplibre-gl\n  useEffect(() => {\n    let protocol = new Protocol()\n    maplibregl.addProtocol('pmtiles', protocol.tile)\n    return () => {\n      maplibregl.removeProtocol('pmtiles')\n    }\n  }, [])\n\n  return (\n    <MapProvider>\n      <Map\n        reuseMaps\n        style={{\n          width: '100%',\n          height: '100vh',\n        }}\n        mapStyle={`${window.location.protocol}//${window.location.hostname}:${window.location.port}/api/maps/styles`}\n        mapLib={maplibregl}\n        initialViewState={{\n          longitude: -101,\n          latitude: 40,\n          zoom: 3.5,\n        }}\n      >\n        <NavigationControl style={{ marginTop: '110px', marginRight: '36px' }} />\n        <FullscreenControl style={{ marginTop: '30px', marginRight: '36px' }} />\n      </Map>\n    </MapProvider>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/components/markdoc/Heading.tsx",
    "content": "import React, { JSX } from 'react'\n\nexport function Heading({\n  level,\n  id,\n  children,\n}: {\n  level: number\n  id: string\n  children: React.ReactNode\n}) {\n  const Component = `h${level}` as keyof JSX.IntrinsicElements\n  const styles: Record<number, string> = {\n    1: 'text-3xl font-bold text-desert-green-darker pb-3 mb-6 mt-2 border-b-2 border-desert-orange',\n    2: 'text-2xl font-bold text-desert-green-dark pb-2 mb-5 mt-10 border-b border-desert-tan-lighter',\n    3: 'text-xl font-semibold text-desert-green-dark mb-3 mt-8',\n    4: 'text-lg font-semibold text-desert-green mb-2 mt-6',\n    5: 'text-base font-semibold text-desert-green mb-2 mt-5',\n    6: 'text-sm font-semibold text-desert-green mb-2 mt-4',\n  }\n\n  return (\n    // @ts-ignore\n    <Component id={id} className={styles[level]}>\n      {children}\n    </Component>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/components/markdoc/Image.tsx",
    "content": "export function Image({ src, alt, title }: { src: string; alt?: string; title?: string }) {\n  return (\n    <figure className=\"my-8\">\n      <div className=\"overflow-hidden rounded-lg border border-desert-tan-lighter shadow-md\">\n        <img\n          src={src}\n          alt={alt || ''}\n          title={title}\n          className=\"w-full h-auto\"\n          loading=\"lazy\"\n        />\n      </div>\n      {alt && (\n        <figcaption className=\"mt-3 text-center text-sm text-desert-stone italic\">\n          {alt}\n        </figcaption>\n      )}\n    </figure>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/components/markdoc/List.tsx",
    "content": "export function List({\n  ordered = false,\n  start,\n  children,\n}: {\n  ordered?: boolean\n  start?: number\n  children: React.ReactNode\n}) {\n  const className = ordered\n    ? 'list-decimal list-outside ml-6 mb-5 space-y-2 marker:text-desert-orange marker:font-semibold'\n    : 'list-disc list-outside ml-6 mb-5 space-y-2 marker:text-desert-orange'\n  const Tag = ordered ? 'ol' : 'ul'\n  return (\n    // @ts-ignore\n    <Tag start={ordered ? start : undefined} className={className}>\n      {children}\n    </Tag>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/components/markdoc/ListItem.tsx",
    "content": "export function ListItem({ children }: { children: React.ReactNode }) {\n  return <li className=\"pl-2 text-desert-green-darker/85 leading-relaxed\">{children}</li>\n}\n"
  },
  {
    "path": "admin/inertia/components/markdoc/Table.tsx",
    "content": "export function Table({ children }: { children: React.ReactNode }) {\n  return (\n    <div className=\"overflow-x-auto my-6 rounded-lg border border-desert-tan-lighter shadow-sm\">\n      <table className=\"min-w-full divide-y divide-desert-tan-lighter\">\n        {children}\n      </table>\n    </div>\n  )\n}\n\nexport function TableHead({ children }: { children: React.ReactNode }) {\n  return <thead className=\"bg-desert-green\">{children}</thead>\n}\n\nexport function TableBody({ children }: { children: React.ReactNode }) {\n  return <tbody className=\"divide-y divide-desert-tan-lighter/50 bg-surface-primary\">{children}</tbody>\n}\n\nexport function TableRow({ children }: { children: React.ReactNode }) {\n  return <tr className=\"hover:bg-desert-sand/40 transition-colors\">{children}</tr>\n}\n\nexport function TableHeader({ children }: { children: React.ReactNode }) {\n  return (\n    <th className=\"px-5 py-3 text-left text-sm font-semibold text-white tracking-wide\">\n      {children}\n    </th>\n  )\n}\n\nexport function TableCell({ children }: { children: React.ReactNode }) {\n  return (\n    <td className=\"px-5 py-3.5 text-sm text-desert-green-darker\">\n      {children}\n    </td>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/components/systeminfo/CircularGauge.tsx",
    "content": "import { useEffect, useState } from 'react'\nimport classNames from '~/lib/classNames'\n\ninterface CircularGaugeProps {\n  value: number // percentage\n  label: string\n  icon?: React.ReactNode\n  size?: 'sm' | 'md' | 'lg'\n  variant?: 'cpu' | 'memory' | 'disk' | 'default'\n  subtext?: string\n  animated?: boolean\n}\n\nexport default function CircularGauge({\n  value,\n  label,\n  icon,\n  size = 'md',\n  variant = 'default',\n  subtext,\n  animated = true,\n}: CircularGaugeProps) {\n  const [animatedValue, setAnimatedValue] = useState(animated ? 0 : value)\n\n  useEffect(() => {\n    if (animated) {\n      const timeout = setTimeout(() => setAnimatedValue(value), 100)\n      return () => clearTimeout(timeout)\n    }\n  }, [value, animated])\n\n  const displayValue = animated ? animatedValue : value\n\n  // Size configs: container size must match SVG size (2 * (radius + strokeWidth))\n  const sizes = {\n    sm: {\n      container: 'w-28 h-28',  // 112px = 2 * (48 + 8)\n      strokeWidth: 8,\n      radius: 48,\n      fontSize: 'text-xl',\n      labelSize: 'text-xs',\n    },\n    md: {\n      container: 'w-[140px] h-[140px]',  // 140px = 2 * (60 + 10)\n      strokeWidth: 10,\n      radius: 60,\n      fontSize: 'text-2xl',\n      labelSize: 'text-sm',\n    },\n    lg: {\n      container: 'w-[244px] h-[244px]',  // 244px = 2 * (110 + 12)\n      strokeWidth: 12,\n      radius: 110,\n      fontSize: 'text-4xl',\n      labelSize: 'text-base',\n    },\n  }\n\n  const config = sizes[size]\n  const circumference = 2 * Math.PI * config.radius\n  const offset = circumference - (displayValue / 100) * circumference\n\n  const getColor = () => {\n    // For benchmarks: higher scores = better = green\n    if (value >= 75) return 'desert-green'\n    if (value >= 50) return 'desert-olive'\n    if (value >= 25) return 'desert-orange'\n    return 'desert-red'\n  }\n\n  const color = getColor()\n\n  const center = config.radius + config.strokeWidth\n\n  return (\n    <div className=\"flex flex-col items-center gap-3\">\n      <div className={classNames('relative', config.container)}>\n        <svg\n          className=\"transform -rotate-90\"\n          width={center * 2}\n          height={center * 2}\n          viewBox={`0 0 ${center * 2} ${center * 2}`}\n        >\n          {/* Background circle */}\n          <circle\n            cx={center}\n            cy={center}\n            r={config.radius}\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={config.strokeWidth}\n            className=\"text-desert-green-lighter opacity-30\"\n          />\n\n          {/* Progress circle */}\n          <circle\n            cx={center}\n            cy={center}\n            r={config.radius}\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={config.strokeWidth}\n            strokeDasharray={circumference}\n            strokeDashoffset={offset}\n            strokeLinecap=\"round\"\n            className={classNames(\n              `text-${color}`,\n              'transition-all duration-1000 ease-out',\n              'drop-shadow-[0_0_1px_currentColor]'\n            )}\n            style={{\n              filter: 'drop-shadow(0 0 1px currentColor)',\n            }}\n          />\n\n          {/* Tick marks */}\n          {Array.from({ length: 12 }).map((_, i) => {\n            const angle = (i * 30 * Math.PI) / 180\n            const ringGap = 8\n            const tickLength = 6\n            const innerRadius = config.radius - config.strokeWidth - ringGap\n            const outerRadius = config.radius - config.strokeWidth - ringGap - tickLength\n            const x1 = center + innerRadius * Math.cos(angle)\n            const y1 = center + innerRadius * Math.sin(angle)\n            const x2 = center + outerRadius * Math.cos(angle)\n            const y2 = center + outerRadius * Math.sin(angle)\n\n            return (\n              <line\n                key={i}\n                x1={x1}\n                y1={y1}\n                x2={x2}\n                y2={y2}\n                stroke=\"currentColor\"\n                strokeWidth=\"2\"\n                className=\"text-desert-stone opacity-30\"\n              />\n            )\n          })}\n        </svg>\n        <div className=\"absolute inset-0 flex flex-col items-center justify-center\">\n          {icon && <div className=\"text-desert-green opacity-60 mb-1\">{icon}</div>}\n          <div className={classNames('font-bold text-desert-green', config.fontSize)}>\n            {Math.round(displayValue)}%\n          </div>\n          {subtext && (\n            <div className=\"text-xs text-desert-stone-dark opacity-70 font-mono mt-0.5\">\n              {subtext}\n            </div>\n          )}\n        </div>\n      </div>\n      <div className={classNames('font-semibold text-desert-green text-center', config.labelSize)}>\n        {label}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/components/systeminfo/InfoCard.tsx",
    "content": "import classNames from '~/lib/classNames'\n\ninterface InfoCardProps {\n  title: string\n  icon?: React.ReactNode\n  data: Array<{\n    label: string\n    value: string | number | undefined\n  }>\n  variant?: 'default' | 'bordered' | 'elevated'\n}\n\nexport default function InfoCard({ title, icon, data, variant = 'default' }: InfoCardProps) {\n  const getVariantStyles = () => {\n    switch (variant) {\n      case 'bordered':\n        return 'border-2 border-desert-green bg-desert-white'\n      case 'elevated':\n        return 'bg-desert-white shadow-lg border border-desert-stone-lighter'\n      default:\n        return 'bg-desert-white border border-desert-stone-light'\n    }\n  }\n\n  return (\n    <div\n      className={classNames(\n        'rounded-lg overflow-hidden transition-all duration-200 hover:shadow-xl',\n        getVariantStyles()\n      )}\n    >\n      <div className=\"relative bg-desert-green px-6 py-4 overflow-hidden\">\n        {/* Diagonal line pattern */}\n        <div\n          className=\"absolute inset-0 opacity-10\"\n          style={{\n            backgroundImage: `repeating-linear-gradient(\n              45deg,\n              transparent,\n              transparent 10px,\n              rgba(255, 255, 255, 0.1) 10px,\n              rgba(255, 255, 255, 0.1) 20px\n            )`,\n          }}\n        />\n\n        <div className=\"relative flex items-center gap-3\">\n          {icon && <div className=\"text-white opacity-80\">{icon}</div>}\n          <h3 className=\"text-lg font-bold text-white uppercase tracking-wide\">{title}</h3>\n        </div>\n        <div className=\"absolute top-0 right-0 w-24 h-24 transform translate-x-8 -translate-y-8\">\n          <div className=\"w-full h-full bg-desert-green-dark opacity-30 transform rotate-45\" />\n        </div>\n      </div>\n      <div className=\"p-6\">\n        <dl className=\"grid grid-cols-1 gap-4\">\n          {data.map((item, index) => (\n            <div\n              key={index}\n              className={classNames(\n                'flex justify-between items-center py-2 border-b border-desert-stone-lighter last:border-b-0'\n              )}\n            >\n              <dt className=\"text-sm font-medium text-desert-stone-dark flex items-center gap-2\">\n                {item.label}\n              </dt>\n              <dd className={classNames('text-sm font-semibold text-right text-desert-green-dark')}>\n                {item.value || 'N/A'}\n              </dd>\n            </div>\n          ))}\n        </dl>\n      </div>\n      <div className=\"h-1 bg-desert-green\" />\n    </div>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/components/systeminfo/StatusCard.tsx",
    "content": "export type StatusCardProps = {\n  title: string\n  value: string | number\n}\n\nexport default function StatusCard({ title, value }: StatusCardProps) {\n  return (\n    <div className=\"bg-desert-white rounded-lg p-6 border border-desert-stone-light\">\n      <div className=\"flex items-center justify-between mb-2\">\n        <span className=\"text-sm font-medium text-desert-stone-dark\">{title}</span>\n        <div className=\"w-2 h-2 bg-desert-olive rounded-full animate-pulse\" />\n      </div>\n      <div className=\"text-2xl font-bold text-desert-green\">{value}</div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/context/ModalContext.ts",
    "content": "import { createContext, useContext, ReactNode } from 'react'\n\ninterface ModalContextProps {\n  openModal: (content: ReactNode, id: string, preventClose?: boolean) => void\n  closeModal: (id: string) => void\n  closeAllModals: () => void\n  _getCurrentModals: () => Record<string, ReactNode>\n  preventCloseOnOverlayClick?: boolean\n}\n\nexport const ModalContext = createContext<ModalContextProps | undefined>(undefined)\n\nexport const useModals = () => {\n  const context = useContext(ModalContext)\n  if (!context) {\n    throw new Error('useModal must be used within a ModalProvider')\n  }\n  return context\n}\n"
  },
  {
    "path": "admin/inertia/context/NotificationContext.ts",
    "content": "import { createContext, useContext } from \"react\";\n\nexport interface Notification {\n  message: string;\n  type: \"error\" | \"success\" | \"info\";\n  duration?: number; // in milliseconds\n}\n\nexport interface NotificationContextType {\n  notifications: Notification[];\n  addNotification: (notification: Notification) => void;\n  removeNotification: (id: string) => void;\n  removeAllNotifications: () => void;\n}\n\nexport const NotificationContext = createContext<\n  NotificationContextType | undefined\n>(undefined);\n\nexport const useNotifications = () => {\n  const context = useContext(NotificationContext);\n  if (!context) {\n    throw new Error(\n      \"useNotifications must be used within a NotificationProvider\"\n    );\n  }\n  return context;\n};\n"
  },
  {
    "path": "admin/inertia/css/app.css",
    "content": "@import 'tailwindcss';\n\n@theme {\n  --color-desert-white: #f6f6f4;\n  --color-desert-sand: #f7eedc;\n\n  --color-desert-green-darker: #2a2a15;\n  --color-desert-green-dark: #353518;\n  --color-desert-green: #424420;\n  --color-desert-green-light: #babaaa;\n  --color-desert-green-lighter: #d4d4c8;\n\n  --color-desert-orange-dark: #8a3d0f;\n  --color-desert-orange: #a84a12;\n  --color-desert-orange-light: #c85815;\n  --color-desert-orange-lighter: #e69556;\n\n  --color-desert-tan-dark: #6b5d4f;\n  --color-desert-tan: #8b7355;\n  --color-desert-tan-light: #a8927a;\n  --color-desert-tan-lighter: #c9b99f;\n\n  --color-desert-red-dark: #7a2e2e;\n  --color-desert-red: #994444;\n  --color-desert-red-light: #b05555;\n  --color-desert-red-lighter: #d88989;\n\n  --color-desert-olive-dark: #5a5c3a;\n  --color-desert-olive: #6d7042;\n  --color-desert-olive-light: #858a55;\n  --color-desert-olive-lighter: #a5ab7d;\n\n  --color-desert-stone-dark: #5c5c54;\n  --color-desert-stone: #75756a;\n  --color-desert-stone-light: #8f8f82;\n  --color-desert-stone-lighter: #afafa5;\n\n  /* Semantic surface/text tokens (for replacing generic gray/white Tailwind classes) */\n  --color-surface-primary: #ffffff;\n  --color-surface-secondary: #f9fafb;\n  --color-surface-elevated: #ffffff;\n  --color-text-primary: #111827;\n  --color-text-secondary: #6b7280;\n  --color-text-muted: #9ca3af;\n  --color-border-default: #d1d5db;\n  --color-border-subtle: #e5e7eb;\n\n  /* Button interactive states (green hover/active swap conflicts with text color inversion) */\n  --color-btn-green-hover: #353518;\n  --color-btn-green-active: #2a2a15;\n}\n\nbody {\n  background-color: var(--color-desert-sand);\n  color: var(--color-text-primary);\n  transition: background-color 0.2s ease, color 0.2s ease;\n}\n\n/* Night Ops — warm charcoal dark mode */\n[data-theme=\"dark\"] {\n  /* Backgrounds: light sand → warm charcoal */\n  --color-desert-sand: #1c1b16;\n  --color-desert-white: #2a2918;\n\n  /* Text greens: dark text → light text for readability */\n  --color-desert-green-darker: #f7eedc;\n  --color-desert-green-dark: #e8dfc8;\n\n  /* Accent green: slightly brighter for dark bg visibility */\n  --color-desert-green: #525530;\n\n  /* Light variants → dark variants (hover bg, disabled states) */\n  --color-desert-green-light: #3a3c24;\n  --color-desert-green-lighter: #2d2e1c;\n\n  /* Orange: brighter for contrast on dark surfaces */\n  --color-desert-orange-dark: #c85815;\n  --color-desert-orange: #c85815;\n  --color-desert-orange-light: #e69556;\n  --color-desert-orange-lighter: #f0b87a;\n\n  /* Tan: lightened for readability */\n  --color-desert-tan-dark: #c9b99f;\n  --color-desert-tan: #a8927a;\n  --color-desert-tan-light: #8b7355;\n  --color-desert-tan-lighter: #6b5d4f;\n\n  /* Red: lightened for dark bg */\n  --color-desert-red-dark: #d88989;\n  --color-desert-red: #b05555;\n  --color-desert-red-light: #994444;\n  --color-desert-red-lighter: #7a2e2e;\n\n  /* Olive: lightened */\n  --color-desert-olive-dark: #a5ab7d;\n  --color-desert-olive: #858a55;\n  --color-desert-olive-light: #6d7042;\n  --color-desert-olive-lighter: #5a5c3a;\n\n  /* Stone: lightened */\n  --color-desert-stone-dark: #afafa5;\n  --color-desert-stone: #8f8f82;\n  --color-desert-stone-light: #75756a;\n  --color-desert-stone-lighter: #5c5c54;\n\n  /* Semantic surface overrides */\n  --color-surface-primary: #2a2918;\n  --color-surface-secondary: #353420;\n  --color-surface-elevated: #3d3c2a;\n  --color-text-primary: #f7eedc;\n  --color-text-secondary: #afafa5;\n  --color-text-muted: #8f8f82;\n  --color-border-default: #424420;\n  --color-border-subtle: #353420;\n\n  /* Button interactive states: darker green for hover/active on dark bg */\n  --color-btn-green-hover: #474a28;\n  --color-btn-green-active: #3a3c24;\n\n  color-scheme: dark;\n}"
  },
  {
    "path": "admin/inertia/hooks/useDebounce.ts",
    "content": "import { useRef, useEffect } from \"react\";\n\nconst useDebounce = () => {\n  const timeout = useRef<number | undefined>(400);\n\n  const debounce =\n    (func: Function, wait: number = 0) =>\n    (...args: any[]) => {\n      clearTimeout(timeout.current);\n      timeout.current = window.setTimeout(() => func(...args), wait);\n    };\n\n  useEffect(() => {\n    return () => {\n      if (!timeout.current) return;\n      clearTimeout(timeout.current);\n    };\n  }, []);\n\n  return { debounce };\n};\n\nexport default useDebounce;\n"
  },
  {
    "path": "admin/inertia/hooks/useDiskDisplayData.ts",
    "content": "import { NomadDiskInfo } from '../../types/system'\nimport { Systeminformation } from 'systeminformation'\nimport { formatBytes } from '~/lib/util'\n\ntype DiskDisplayItem = {\n  label: string\n  value: number\n  total: string\n  used: string\n  subtext: string\n  totalBytes: number\n  usedBytes: number\n}\n\n/** Get all valid disks formatted for display (settings/system page) */\nexport function getAllDiskDisplayItems(\n  disks: NomadDiskInfo[] | undefined,\n  fsSize: Systeminformation.FsSizeData[] | undefined\n): DiskDisplayItem[] {\n  const validDisks = disks?.filter((d) => d.totalSize > 0) || []\n\n  if (validDisks.length > 0) {\n    return validDisks.map((disk) => ({\n      label: disk.name || 'Unknown',\n      value: disk.percentUsed || 0,\n      total: formatBytes(disk.totalSize),\n      used: formatBytes(disk.totalUsed),\n      subtext: `${formatBytes(disk.totalUsed || 0)} / ${formatBytes(disk.totalSize || 0)}`,\n      totalBytes: disk.totalSize,\n      usedBytes: disk.totalUsed,\n    }))\n  }\n\n  if (fsSize && fsSize.length > 0) {\n    const seen = new Set<number>()\n    const uniqueFs = fsSize.filter((fs) => {\n      if (fs.size <= 0 || seen.has(fs.size)) return false\n      seen.add(fs.size)\n      return true\n    })\n    const realDevices = uniqueFs.filter((fs) => fs.fs.startsWith('/dev/'))\n    const displayFs = realDevices.length > 0 ? realDevices : uniqueFs\n    return displayFs.map((fs) => ({\n      label: fs.fs || 'Unknown',\n      value: fs.use || 0,\n      total: formatBytes(fs.size),\n      used: formatBytes(fs.used),\n      subtext: `${formatBytes(fs.used)} / ${formatBytes(fs.size)}`,\n      totalBytes: fs.size,\n      usedBytes: fs.used,\n    }))\n  }\n\n  return []\n}\n\n/** Get primary disk info for storage projection (easy-setup page) */\nexport function getPrimaryDiskInfo(\n  disks: NomadDiskInfo[] | undefined,\n  fsSize: Systeminformation.FsSizeData[] | undefined\n): { totalSize: number; totalUsed: number } | null {\n  const validDisks = disks?.filter((d) => d.totalSize > 0) || []\n  if (validDisks.length > 0) {\n    const diskWithRoot = validDisks.find((d) =>\n      d.filesystems?.some((fs) => fs.mount === '/' || fs.mount === '/storage')\n    )\n    const primary =\n      diskWithRoot || validDisks.reduce((a, b) => (b.totalSize > a.totalSize ? b : a))\n    return { totalSize: primary.totalSize, totalUsed: primary.totalUsed }\n  }\n\n  if (fsSize && fsSize.length > 0) {\n    const realDevices = fsSize.filter((fs) => fs.fs.startsWith('/dev/'))\n    const primary =\n      realDevices.length > 0\n        ? realDevices.reduce((a, b) => (b.size > a.size ? b : a))\n        : fsSize[0]\n    return { totalSize: primary.size, totalUsed: primary.used }\n  }\n\n  return null\n}\n"
  },
  {
    "path": "admin/inertia/hooks/useDownloads.ts",
    "content": "import { useQuery, useQueryClient } from '@tanstack/react-query'\nimport { useMemo } from 'react'\nimport api from '~/lib/api'\n\nexport type useDownloadsProps = {\n  filetype?: string\n  enabled?: boolean\n}\n\nconst useDownloads = (props: useDownloadsProps) => {\n  const queryClient = useQueryClient()\n\n  const queryKey = useMemo(() => {\n    return props.filetype ? ['download-jobs', props.filetype] : ['download-jobs']\n  }, [props.filetype])\n\n  const queryData = useQuery({\n    queryKey: queryKey,\n    queryFn: () => api.listDownloadJobs(props.filetype),\n    refetchInterval: (query) => {\n      const data = query.state.data\n      // Only poll when there are active downloads; otherwise use a slower interval\n      return data && data.length > 0 ? 2000 : 30000\n    },\n    enabled: props.enabled ?? true,\n  })\n\n  const invalidate = () => {\n    queryClient.invalidateQueries({ queryKey: queryKey })\n  }\n\n  return { ...queryData, invalidate }\n}\n\nexport default useDownloads\n"
  },
  {
    "path": "admin/inertia/hooks/useEmbedJobs.ts",
    "content": "import { useQuery, useQueryClient } from '@tanstack/react-query'\nimport api from '~/lib/api'\n\nconst useEmbedJobs = (props: { enabled?: boolean } = {}) => {\n  const queryClient = useQueryClient()\n\n  const queryData = useQuery({\n    queryKey: ['embed-jobs'],\n    queryFn: () => api.getActiveEmbedJobs().then((data) => data ?? []),\n    refetchInterval: (query) => {\n      const data = query.state.data\n      // Only poll when there are active jobs; otherwise use a slower interval\n      return data && data.length > 0 ? 2000 : 30000\n    },\n    enabled: props.enabled ?? true,\n  })\n\n  const invalidate = () => {\n    queryClient.invalidateQueries({ queryKey: ['embed-jobs'] })\n  }\n\n  return { ...queryData, invalidate }\n}\n\nexport default useEmbedJobs\n"
  },
  {
    "path": "admin/inertia/hooks/useErrorNotification.ts",
    "content": "// Helper hook to show error notifications\nimport { useNotifications } from '../context/NotificationContext';\n\nconst useErrorNotification = () => {\n  const { addNotification } = useNotifications();\n\n  const showError = (message: string) => {\n    addNotification({ message, type: 'error' });\n  };\n\n  return { showError };\n};\n\nexport default useErrorNotification;\n"
  },
  {
    "path": "admin/inertia/hooks/useInternetStatus.ts",
    "content": "// Helper hook to check internet connection status\nimport { useQuery } from '@tanstack/react-query';\nimport { useEffect, useState } from 'react';\nimport api from '~/lib/api';\n\nconst useInternetStatus = () => {\n    const [isOnline, setIsOnline] = useState<boolean>(true); // Initialize true to avoid \"offline\" flicker on load\n    const { data } = useQuery<boolean>({\n        queryKey: ['internetStatus'],\n        queryFn: async () => (await api.getInternetStatus()) ?? false,\n        refetchOnWindowFocus: false, // Don't refetch on window focus\n        refetchOnReconnect: true, // Refetch when the browser reconnects\n        refetchOnMount: false, // Don't refetch when the component mounts\n        retry: 0, // Retry already handled in backend\n        staleTime: 1000 * 60 * 5, // Data is fresh for 5 minutes\n    });\n\n    // Update the online status when data changes\n    useEffect(() => {\n        if (data === undefined) return; // Avoid setting state on unmounted component\n        setIsOnline(data);\n    }, [data]);\n\n    return { isOnline };\n};\n\nexport default useInternetStatus;"
  },
  {
    "path": "admin/inertia/hooks/useMapRegionFiles.ts",
    "content": "import { useQuery } from '@tanstack/react-query'\nimport { FileEntry } from '../../types/files'\nimport api from '~/lib/api'\n\nconst useMapRegionFiles = () => {\n  return useQuery<FileEntry[]>({\n    queryKey: ['map-region-files'],\n    queryFn: () => api.listMapRegionFiles(),\n    staleTime: 5 * 60 * 1000, // 5 minutes\n  })\n}\n\nexport default useMapRegionFiles\n"
  },
  {
    "path": "admin/inertia/hooks/useOllamaModelDownloads.ts",
    "content": "import { useEffect, useRef, useState } from 'react'\nimport { useTransmit } from 'react-adonis-transmit'\n\nexport type OllamaModelDownload = {\n    model: string\n    percent: number\n    timestamp: string\n}\n\nexport default function useOllamaModelDownloads() {\n    const { subscribe } = useTransmit()\n    const [downloads, setDownloads] = useState<Map<string, OllamaModelDownload>>(new Map())\n    const timeoutsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set())\n\n    useEffect(() => {\n        const unsubscribe = subscribe('ollama-model-download', (data: OllamaModelDownload) => {\n            setDownloads((prev) => {\n                const updated = new Map(prev)\n\n                if (data.percent >= 100) {\n                    // If download is complete, keep it for a short time before removing to allow UI to show 100% progress\n                    updated.set(data.model, data)\n                    const timeout = setTimeout(() => {\n                        timeoutsRef.current.delete(timeout)\n                        setDownloads((current) => {\n                            const next = new Map(current)\n                            next.delete(data.model)\n                            return next\n                        })\n                    }, 2000)\n                    timeoutsRef.current.add(timeout)\n                } else {\n                    updated.set(data.model, data)\n                }\n\n                return updated\n            })\n        })\n\n        return () => {\n            unsubscribe()\n            timeoutsRef.current.forEach(clearTimeout)\n            timeoutsRef.current.clear()\n        }\n        // eslint-disable-next-line react-hooks/exhaustive-deps\n    }, [subscribe])\n\n    const downloadsArray = Array.from(downloads.values())\n\n    return { downloads: downloadsArray, activeCount: downloads.size }\n}\n"
  },
  {
    "path": "admin/inertia/hooks/useServiceInstallationActivity.ts",
    "content": "import { useEffect, useState } from 'react'\nimport { useTransmit } from 'react-adonis-transmit'\nimport { InstallActivityFeedProps } from '~/components/InstallActivityFeed'\nimport { BROADCAST_CHANNELS } from '../../constants/broadcast'\n\nexport default function useServiceInstallationActivity() {\n  const { subscribe } = useTransmit()\n  const [installActivity, setInstallActivity] = useState<InstallActivityFeedProps['activity']>([])\n\n  useEffect(() => {\n    const unsubscribe = subscribe(BROADCAST_CHANNELS.SERVICE_INSTALLATION, (data: any) => {\n      setInstallActivity((prev) => [\n        ...prev,\n        {\n          service_name: data.service_name ?? 'unknown',\n          type: data.status ?? 'unknown',\n          timestamp: new Date().toISOString(),\n          message: data.message ?? 'No message provided',\n        },\n      ])\n    })\n\n    return () => {\n      unsubscribe()\n    }\n  }, [])\n\n  return installActivity\n}\n"
  },
  {
    "path": "admin/inertia/hooks/useServiceInstalledStatus.tsx",
    "content": "import { useQuery } from '@tanstack/react-query'\nimport { ServiceSlim } from '../../types/services'\nimport api from '~/lib/api'\n\nconst useServiceInstalledStatus = (serviceName: string) => {\n  const { data, isFetching } = useQuery<ServiceSlim[] | undefined>({\n    queryKey: ['installed-services'],\n    queryFn: () => api.getSystemServices(),\n  })\n\n  const isInstalled = data?.some(\n    (service) => service.service_name === serviceName && service.installed\n  )\n\n  return { isInstalled, loading: isFetching }\n}\n\nexport default useServiceInstalledStatus\n"
  },
  {
    "path": "admin/inertia/hooks/useSystemInfo.ts",
    "content": "import { useQuery, UseQueryOptions } from '@tanstack/react-query'\nimport { SystemInformationResponse } from '../../types/system'\nimport api from '~/lib/api'\n\nexport type UseSystemInfoProps = Omit<\n  UseQueryOptions<SystemInformationResponse | undefined>,\n  'queryKey' | 'queryFn'\n> & {}\n\nexport const useSystemInfo = (props: UseSystemInfoProps) => {\n  const queryData = useQuery<SystemInformationResponse | undefined>({\n    ...props,\n    queryKey: ['system-info'],\n    queryFn: async () => await api.getSystemInfo(),\n    refetchInterval: 45000, // Refetch every 45 seconds\n  })\n\n  return queryData\n}\n"
  },
  {
    "path": "admin/inertia/hooks/useSystemSetting.ts",
    "content": "import { useQuery, UseQueryOptions } from '@tanstack/react-query'\nimport api from '~/lib/api'\nimport { KVStoreKey } from '../../types/kv_store';\n\nexport type UseSystemSettingProps = Omit<\n  UseQueryOptions<{ key: string; value: any } | undefined>,\n  'queryKey' | 'queryFn'\n> & {\n  key: KVStoreKey\n}\n\nexport const useSystemSetting = (props: UseSystemSettingProps) => {\n  const { key, ...queryOptions } = props\n\n  const queryData = useQuery<{ key: string; value: any } | undefined>({\n    ...queryOptions,\n    queryKey: ['system-setting', key],\n    queryFn: async () => await api.getSetting(key),\n  })\n\n  return queryData\n}\n"
  },
  {
    "path": "admin/inertia/hooks/useTheme.ts",
    "content": "import { useState, useEffect, useCallback } from 'react'\nimport api from '~/lib/api'\n\nexport type Theme = 'light' | 'dark'\n\nconst STORAGE_KEY = 'nomad:theme'\n\nfunction getInitialTheme(): Theme {\n  try {\n    const stored = localStorage.getItem(STORAGE_KEY)\n    if (stored === 'dark' || stored === 'light') return stored\n  } catch {}\n  return 'light'\n}\n\nexport function useTheme() {\n  const [theme, setThemeState] = useState<Theme>(getInitialTheme)\n\n  const setTheme = useCallback((newTheme: Theme) => {\n    setThemeState(newTheme)\n    document.documentElement.setAttribute('data-theme', newTheme)\n    try {\n      localStorage.setItem(STORAGE_KEY, newTheme)\n    } catch {}\n    // Fire-and-forget KV store sync for cross-device persistence\n    api.updateSetting('ui.theme', newTheme).catch(() => {})\n  }, [])\n\n  const toggleTheme = useCallback(() => {\n    setThemeState((prev) => {\n      const next = prev === 'light' ? 'dark' : 'light'\n      document.documentElement.setAttribute('data-theme', next)\n      try {\n        localStorage.setItem(STORAGE_KEY, next)\n      } catch {}\n      api.updateSetting('ui.theme', next).catch(() => {})\n      return next\n    })\n  }, [])\n\n  // Apply theme on mount\n  useEffect(() => {\n    document.documentElement.setAttribute('data-theme', theme)\n  }, [])\n\n  return { theme, setTheme, toggleTheme }\n}\n"
  },
  {
    "path": "admin/inertia/hooks/useUpdateAvailable.ts",
    "content": "import api from \"~/lib/api\"\nimport { CheckLatestVersionResult } from \"../../types/system\"\nimport { useQuery } from \"@tanstack/react-query\"\n\n\nexport const useUpdateAvailable = () => {\n    const queryData = useQuery<CheckLatestVersionResult | undefined>({\n        queryKey: ['system-update-available'],\n        queryFn: () => api.checkLatestVersion(),\n        refetchInterval: Infinity, // Disable automatic refetching\n        refetchOnWindowFocus: false,\n    })\n\n    return queryData.data\n}"
  },
  {
    "path": "admin/inertia/layouts/AppLayout.tsx",
    "content": "import { useState } from 'react'\nimport Footer from '~/components/Footer'\nimport ChatButton from '~/components/chat/ChatButton'\nimport ChatModal from '~/components/chat/ChatModal'\nimport useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'\nimport { SERVICE_NAMES } from '../../constants/service_names'\nimport { Link } from '@inertiajs/react'\nimport { IconArrowLeft } from '@tabler/icons-react'\nimport classNames from 'classnames'\n\nexport default function AppLayout({ children }: { children: React.ReactNode }) {\n  const [isChatOpen, setIsChatOpen] = useState(false)\n  const aiAssistantInstalled = useServiceInstalledStatus(SERVICE_NAMES.OLLAMA)\n\n  return (\n    <div className=\"min-h-screen flex flex-col\">\n      {\n        window.location.pathname !== '/home' && (\n          <Link href=\"/home\" className=\"absolute top-60 md:top-48 left-4 flex items-center\">\n            <IconArrowLeft className=\"mr-2\" size={24} />\n            <p className=\"text-lg text-text-secondary\">Back to Home</p>\n          </Link>\n        )}\n      <div\n        className=\"p-2 flex gap-2 flex-col items-center justify-center cursor-pointer\"\n        onClick={() => (window.location.href = '/home')}\n      >\n        <img src=\"/project_nomad_logo.png\" alt=\"Project Nomad Logo\" className=\"h-40 w-40\" />\n        <h1 className=\"text-5xl font-bold text-desert-green\">Command Center</h1>\n      </div>\n      <hr className={\n        classNames(\n          \"text-desert-green font-semibold h-[1.5px] bg-desert-green border-none\",\n          window.location.pathname !== '/home' ? \"mt-12 md:mt-0\" : \"mt-0\"\n        )} />\n      <div className=\"flex-1 w-full bg-desert\">{children}</div>\n      <Footer />\n\n      {aiAssistantInstalled && (\n        <>\n          <ChatButton onClick={() => setIsChatOpen(true)} />\n          <ChatModal open={isChatOpen} onClose={() => setIsChatOpen(false)} />\n        </>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/layouts/DocsLayout.tsx",
    "content": "import { useQuery } from '@tanstack/react-query'\nimport { useMemo } from 'react'\nimport StyledSidebar from '~/components/StyledSidebar'\nimport api from '~/lib/api'\n\nexport default function DocsLayout({ children }: { children: React.ReactNode }) {\n  const { data, isLoading } = useQuery<Array<{ title: string; slug: string }>>({\n    queryKey: ['docs'],\n    queryFn: () => api.listDocs(),\n    refetchOnWindowFocus: false,\n    staleTime: Infinity,\n  })\n\n  const items = useMemo(() => {\n    if (isLoading || !data) return []\n\n    return data.map((doc) => ({\n      name: doc.title,\n      href: `/docs/${doc.slug}`,\n      current: false,\n    }))\n  }, [data, isLoading])\n\n  return (\n    <div className=\"min-h-screen flex flex-row bg-desert-white\">\n      <StyledSidebar title=\"Documentation\" items={items} />\n      {children}\n    </div>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/layouts/MapsLayout.tsx",
    "content": "import Footer from '~/components/Footer'\n\nexport default function MapsLayout({ children }: { children: React.ReactNode }) {\n  return (\n    <div className=\"min-h-screen flex flex-col\">\n      <div className=\"flex-1 w-full bg-desert\">{children}</div>\n      <Footer />\n    </div>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/layouts/SettingsLayout.tsx",
    "content": "import {\n  IconArrowBigUpLines,\n  IconChartBar,\n  IconDashboard,\n  IconFolder,\n  IconGavel,\n  IconHeart,\n  IconMapRoute,\n  IconSettings,\n  IconTerminal2,\n  IconWand,\n  IconZoom\n} from '@tabler/icons-react'\nimport { usePage } from '@inertiajs/react'\nimport StyledSidebar from '~/components/StyledSidebar'\nimport { getServiceLink } from '~/lib/navigation'\nimport useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'\nimport { SERVICE_NAMES } from '../../constants/service_names'\n\nexport default function SettingsLayout({ children }: { children: React.ReactNode }) {\n  const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props\n  const aiAssistantInstallStatus = useServiceInstalledStatus(SERVICE_NAMES.OLLAMA)\n\n  const navigation = [\n    ...(aiAssistantInstallStatus.isInstalled ? [{ name: aiAssistantName, href: '/settings/models', icon: IconWand, current: false }] : []),\n    { name: 'Apps', href: '/settings/apps', icon: IconTerminal2, current: false },\n    { name: 'Benchmark', href: '/settings/benchmark', icon: IconChartBar, current: false },\n    { name: 'Content Explorer', href: '/settings/zim/remote-explorer', icon: IconZoom, current: false },\n    { name: 'Content Manager', href: '/settings/zim', icon: IconFolder, current: false },\n    { name: 'Maps Manager', href: '/settings/maps', icon: IconMapRoute, current: false },\n    {\n      name: 'Service Logs & Metrics',\n      href: getServiceLink('9999'),\n      icon: IconDashboard,\n      current: false,\n      target: '_blank',\n    },\n    {\n      name: 'Check for Updates',\n      href: '/settings/update',\n      icon: IconArrowBigUpLines,\n      current: false,\n    },\n    { name: 'System', href: '/settings/system', icon: IconSettings, current: false },\n    { name: 'Support the Project', href: '/settings/support', icon: IconHeart, current: false },\n    { name: 'Legal Notices', href: '/settings/legal', icon: IconGavel, current: false },\n  ]\n\n  return (\n    <div className=\"min-h-screen flex flex-row bg-surface-secondary/90\">\n      <StyledSidebar title=\"Settings\" items={navigation} />\n      {children}\n    </div>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/lib/api.ts",
    "content": "import axios, { AxiosInstance } from 'axios'\nimport { ListRemoteZimFilesResponse, ListZimFilesResponse } from '../../types/zim'\nimport { ServiceSlim } from '../../types/services'\nimport { FileEntry } from '../../types/files'\nimport { CheckLatestVersionResult, SystemInformationResponse, SystemUpdateStatus } from '../../types/system'\nimport { DownloadJobWithProgress, WikipediaState } from '../../types/downloads'\nimport { EmbedJobWithProgress } from '../../types/rag'\nimport type { CategoryWithStatus, CollectionWithStatus, ContentUpdateCheckResult, ResourceUpdateInfo } from '../../types/collections'\nimport { catchInternal } from './util'\nimport { NomadOllamaModel, OllamaChatRequest } from '../../types/ollama'\nimport { ChatResponse, ModelResponse } from 'ollama'\nimport BenchmarkResult from '#models/benchmark_result'\nimport { BenchmarkType, RunBenchmarkResponse, SubmitBenchmarkResponse, UpdateBuilderTagResponse } from '../../types/benchmark'\n\nclass API {\n  private client: AxiosInstance\n\n  constructor() {\n    this.client = axios.create({\n      baseURL: '/api',\n      headers: {\n        'Content-Type': 'application/json',\n      },\n    })\n  }\n\n  async affectService(service_name: string, action: 'start' | 'stop' | 'restart') {\n    return catchInternal(async () => {\n      const response = await this.client.post<{ success: boolean; message: string }>(\n        '/system/services/affect',\n        { service_name, action }\n      )\n      return response.data\n    })()\n  }\n\n  async checkLatestVersion(force: boolean = false) {\n    return catchInternal(async () => {\n      const response = await this.client.get<CheckLatestVersionResult>('/system/latest-version', {\n        params: { force },\n      })\n      return response.data\n    })()\n  }\n\n  async deleteModel(model: string): Promise<{ success: boolean; message: string }> {\n    return catchInternal(async () => {\n      const response = await this.client.delete('/ollama/models', { data: { model } })\n      return response.data\n    })()\n  }\n\n  async downloadBaseMapAssets() {\n    return catchInternal(async () => {\n      const response = await this.client.post<{ success: boolean }>('/maps/download-base-assets')\n      return response.data\n    })()\n  }\n\n  async downloadMapCollection(slug: string): Promise<{\n    message: string\n    slug: string\n    resources: string[] | null\n  }> {\n    return catchInternal(async () => {\n      const response = await this.client.post('/maps/download-collection', { slug })\n      return response.data\n    })()\n  }\n\n  async downloadModel(model: string): Promise<{ success: boolean; message: string }> {\n    return catchInternal(async () => {\n      const response = await this.client.post('/ollama/models', { model })\n      return response.data\n    })()\n  }\n\n  async downloadCategoryTier(categorySlug: string, tierSlug: string): Promise<{\n    message: string\n    categorySlug: string\n    tierSlug: string\n    resources: string[] | null\n  }> {\n    return catchInternal(async () => {\n      const response = await this.client.post('/zim/download-category-tier', { categorySlug, tierSlug })\n      return response.data\n    })()\n  }\n\n  async downloadRemoteMapRegion(url: string) {\n    return catchInternal(async () => {\n      const response = await this.client.post<{ message: string; filename: string; url: string }>(\n        '/maps/download-remote',\n        { url }\n      )\n      return response.data\n    })()\n  }\n\n  async downloadRemoteMapRegionPreflight(url: string) {\n    return catchInternal(async () => {\n      const response = await this.client.post<\n        { filename: string; size: number } | { message: string }\n      >('/maps/download-remote-preflight', { url })\n      return response.data\n    })()\n  }\n\n  async downloadRemoteZimFile(\n    url: string,\n    metadata?: { title: string; summary?: string; author?: string; size_bytes?: number }\n  ) {\n    return catchInternal(async () => {\n      const response = await this.client.post<{ message: string; filename: string; url: string }>(\n        '/zim/download-remote',\n        { url, metadata }\n      )\n      return response.data\n    })()\n  }\n\n  async fetchLatestMapCollections(): Promise<{ success: boolean } | undefined> {\n    return catchInternal(async () => {\n      const response = await this.client.post<{ success: boolean }>(\n        '/maps/fetch-latest-collections'\n      )\n      return response.data\n    })()\n  }\n\n  async checkForContentUpdates() {\n    return catchInternal(async () => {\n      const response = await this.client.post<ContentUpdateCheckResult>('/content-updates/check')\n      return response.data\n    })()\n  }\n\n  async applyContentUpdate(update: ResourceUpdateInfo) {\n    return catchInternal(async () => {\n      const response = await this.client.post<{ success: boolean; jobId?: string; error?: string }>(\n        '/content-updates/apply',\n        update\n      )\n      return response.data\n    })()\n  }\n\n  async applyAllContentUpdates(updates: ResourceUpdateInfo[]) {\n    return catchInternal(async () => {\n      const response = await this.client.post<{\n        results: Array<{ resource_id: string; success: boolean; jobId?: string; error?: string }>\n      }>('/content-updates/apply-all', { updates })\n      return response.data\n    })()\n  }\n\n  async refreshManifests(): Promise<{ success: boolean; changed: Record<string, boolean> } | undefined> {\n    return catchInternal(async () => {\n      const response = await this.client.post<{ success: boolean; changed: Record<string, boolean> }>(\n        '/manifests/refresh'\n      )\n      return response.data\n    })()\n  }\n\n  async checkServiceUpdates() {\n    return catchInternal(async () => {\n      const response = await this.client.post<{ success: boolean; message: string }>(\n        '/system/services/check-updates'\n      )\n      return response.data\n    })()\n  }\n\n  async getAvailableVersions(serviceName: string) {\n    return catchInternal(async () => {\n      const response = await this.client.get<{\n        versions: Array<{ tag: string; isLatest: boolean; releaseUrl?: string }>\n      }>(`/system/services/${serviceName}/available-versions`)\n      return response.data\n    })()\n  }\n\n  async updateService(serviceName: string, targetVersion: string) {\n    return catchInternal(async () => {\n      const response = await this.client.post<{ success: boolean; message: string }>(\n        '/system/services/update',\n        { service_name: serviceName, target_version: targetVersion }\n      )\n      return response.data\n    })()\n  }\n\n  async forceReinstallService(service_name: string) {\n    return catchInternal(async () => {\n      const response = await this.client.post<{ success: boolean; message: string }>(\n        `/system/services/force-reinstall`,\n        { service_name }\n      )\n      return response.data\n    })()\n  }\n\n  async getChatSuggestions(signal?: AbortSignal) {\n    return catchInternal(async () => {\n      const response = await this.client.get<{ suggestions: string[] }>(\n        '/chat/suggestions',\n        { signal }\n      )\n      return response.data.suggestions\n    })()\n  }\n\n  async getDebugInfo() {\n    return catchInternal(async () => {\n      const response = await this.client.get<{ debugInfo: string }>('/system/debug-info')\n      return response.data.debugInfo\n    })()\n  }\n\n  async getInternetStatus() {\n    return catchInternal(async () => {\n      const response = await this.client.get<boolean>('/system/internet-status')\n      return response.data\n    })()\n  }\n\n  async getInstalledModels() {\n    return catchInternal(async () => {\n      const response = await this.client.get<ModelResponse[]>('/ollama/installed-models')\n      return response.data\n    })()\n  }\n\n  async getAvailableModels(params: { query?: string; recommendedOnly?: boolean; limit?: number; force?: boolean }) {\n    return catchInternal(async () => {\n      const response = await this.client.get<{\n        models: NomadOllamaModel[]\n        hasMore: boolean\n      }>('/ollama/models', {\n        params: { sort: 'pulls', ...params },\n      })\n      return response.data\n    })()\n  }\n\n  async sendChatMessage(chatRequest: OllamaChatRequest) {\n    return catchInternal(async () => {\n      const response = await this.client.post<ChatResponse>('/ollama/chat', chatRequest)\n      return response.data\n    })()\n  }\n\n  async streamChatMessage(\n    chatRequest: OllamaChatRequest,\n    onChunk: (content: string, thinking: string, done: boolean) => void,\n    signal?: AbortSignal\n  ): Promise<void> {\n    // Axios doesn't support ReadableStream in browser, so need to use fetch\n    const response = await fetch('/api/ollama/chat', {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify({ ...chatRequest, stream: true }),\n      signal,\n    })\n\n    if (!response.ok || !response.body) {\n      throw new Error(`HTTP error: ${response.status}`)\n    }\n\n    const reader = response.body.getReader()\n    const decoder = new TextDecoder()\n    let buffer = ''\n\n    try {\n      while (true) {\n        const { done, value } = await reader.read()\n        if (done) break\n\n        buffer += decoder.decode(value, { stream: true })\n        const lines = buffer.split('\\n')\n        buffer = lines.pop() || ''\n\n        for (const line of lines) {\n          if (!line.startsWith('data: ')) continue\n          let data: any\n          try {\n            data = JSON.parse(line.slice(6))\n          } catch { continue /* skip malformed chunks */ }\n\n          if (data.error) throw new Error('The model encountered an error. Please try again.')\n\n          onChunk(\n            data.message?.content ?? '',\n            data.message?.thinking ?? '',\n            data.done ?? false\n          )\n        }\n      }\n    } finally {\n      reader.releaseLock()\n    }\n  }\n\n  async getBenchmarkResults() {\n    return catchInternal(async () => {\n      const response = await this.client.get<{ results: BenchmarkResult[], total: number }>('/benchmark/results')\n      return response.data\n    })()\n  }\n\n  async getLatestBenchmarkResult() {\n    return catchInternal(async () => {\n      const response = await this.client.get<{ result: BenchmarkResult | null }>('/benchmark/results/latest')\n      return response.data\n    })()\n  }\n\n  async getChatSessions() {\n    return catchInternal(async () => {\n      const response = await this.client.get<\n        Array<{\n          id: string\n          title: string\n          model: string | null\n          timestamp: string\n          lastMessage: string | null\n        }>\n      >('/chat/sessions')\n      return response.data\n    })()\n  }\n\n  async getChatSession(sessionId: string) {\n    return catchInternal(async () => {\n      const response = await this.client.get<{\n        id: string\n        title: string\n        model: string | null\n        timestamp: string\n        messages: Array<{\n          id: string\n          role: 'system' | 'user' | 'assistant'\n          content: string\n          timestamp: string\n        }>\n      }>(`/chat/sessions/${sessionId}`)\n      return response.data\n    })()\n  }\n\n  async createChatSession(title: string, model?: string) {\n    return catchInternal(async () => {\n      const response = await this.client.post<{\n        id: string\n        title: string\n        model: string | null\n        timestamp: string\n      }>('/chat/sessions', { title, model })\n      return response.data\n    })()\n  }\n\n  async updateChatSession(sessionId: string, data: { title?: string; model?: string }) {\n    return catchInternal(async () => {\n      const response = await this.client.put<{\n        id: string\n        title: string\n        model: string | null\n        timestamp: string\n      }>(`/chat/sessions/${sessionId}`, data)\n      return response.data\n    })()\n  }\n\n  async deleteChatSession(sessionId: string) {\n    return catchInternal(async () => {\n      await this.client.delete(`/chat/sessions/${sessionId}`)\n    })()\n  }\n\n  async deleteAllChatSessions() {\n    return catchInternal(async () => {\n      const response = await this.client.delete<{ success: boolean; message: string }>(\n        '/chat/sessions/all'\n      )\n      return response.data\n    })()\n  }\n\n  async addChatMessage(sessionId: string, role: 'system' | 'user' | 'assistant', content: string) {\n    return catchInternal(async () => {\n      const response = await this.client.post<{\n        id: string\n        role: 'system' | 'user' | 'assistant'\n        content: string\n        timestamp: string\n      }>(`/chat/sessions/${sessionId}/messages`, { role, content })\n      return response.data\n    })()\n  }\n\n  async getActiveEmbedJobs(): Promise<EmbedJobWithProgress[] | undefined> {\n    return catchInternal(async () => {\n      const response = await this.client.get<EmbedJobWithProgress[]>('/rag/active-jobs')\n      return response.data\n    })()\n  }\n\n  async getStoredRAGFiles() {\n    return catchInternal(async () => {\n      const response = await this.client.get<{ files: string[] }>('/rag/files')\n      return response.data.files\n    })()\n  }\n\n  async deleteRAGFile(source: string) {\n    return catchInternal(async () => {\n      const response = await this.client.delete<{ message: string }>('/rag/files', { data: { source } })\n      return response.data\n    })()\n  }\n\n  async getSystemInfo() {\n    return catchInternal(async () => {\n      const response = await this.client.get<SystemInformationResponse>('/system/info')\n      return response.data\n    })()\n  }\n\n  async getSystemServices() {\n    return catchInternal(async () => {\n      const response = await this.client.get<Array<ServiceSlim>>('/system/services')\n      return response.data\n    })()\n  }\n\n  async getSystemUpdateStatus() {\n    return catchInternal(async () => {\n      const response = await this.client.get<SystemUpdateStatus>('/system/update/status')\n      return response.data\n    })()\n  }\n\n  async getSystemUpdateLogs() {\n    return catchInternal(async () => {\n      const response = await this.client.get<{ logs: string }>('/system/update/logs')\n      return response.data\n    })()\n  }\n\n  async healthCheck() {\n    return catchInternal(async () => {\n      const response = await this.client.get<{ status: string }>('/health', {\n        timeout: 5000,\n      })\n      return response.data\n    })()\n  }\n\n  async installService(service_name: string) {\n    return catchInternal(async () => {\n      const response = await this.client.post<{ success: boolean; message: string }>(\n        '/system/services/install',\n        { service_name }\n      )\n      return response.data\n    })()\n  }\n\n  async listCuratedMapCollections() {\n    return catchInternal(async () => {\n      const response = await this.client.get<CollectionWithStatus[]>(\n        '/maps/curated-collections'\n      )\n      return response.data\n    })()\n  }\n\n  async listCuratedCategories() {\n    return catchInternal(async () => {\n      const response = await this.client.get<CategoryWithStatus[]>('/easy-setup/curated-categories')\n      return response.data\n    })()\n  }\n\n  async listDocs() {\n    return catchInternal(async () => {\n      const response = await this.client.get<Array<{ title: string; slug: string }>>('/docs/list')\n      return response.data\n    })()\n  }\n\n  async listMapRegionFiles() {\n    return catchInternal(async () => {\n      const response = await this.client.get<{ files: FileEntry[] }>('/maps/regions')\n      return response.data.files\n    })()\n  }\n\n  async listRemoteZimFiles({\n    start = 0,\n    count = 12,\n    query,\n  }: {\n    start?: number\n    count?: number\n    query?: string\n  }) {\n    return catchInternal(async () => {\n      return await this.client.get<ListRemoteZimFilesResponse>('/zim/list-remote', {\n        params: {\n          start,\n          count,\n          query,\n        },\n      })\n    })()\n  }\n\n  async listZimFiles() {\n    return catchInternal(async () => {\n      return await this.client.get<ListZimFilesResponse>('/zim/list')\n    })()\n  }\n\n  async listDownloadJobs(filetype?: string): Promise<DownloadJobWithProgress[] | undefined> {\n    return catchInternal(async () => {\n      const endpoint = filetype ? `/downloads/jobs/${filetype}` : '/downloads/jobs'\n      const response = await this.client.get<DownloadJobWithProgress[]>(endpoint)\n      return response.data\n    })()\n  }\n\n  async removeDownloadJob(jobId: string): Promise<void> {\n    return catchInternal(async () => {\n      await this.client.delete(`/downloads/jobs/${jobId}`)\n    })()\n  }\n\n  async runBenchmark(type: BenchmarkType, sync: boolean = false) {\n    return catchInternal(async () => {\n      const response = await this.client.post<RunBenchmarkResponse>(\n        `/benchmark/run${sync ? '?sync=true' : ''}`,\n        { benchmark_type: type },\n      )\n      return response.data\n    })()\n  }\n\n  async startSystemUpdate() {\n    return catchInternal(async () => {\n      const response = await this.client.post<{ success: boolean; message: string }>(\n        '/system/update'\n      )\n      return response.data\n    })()\n  }\n\n  async submitBenchmark(benchmark_id: string, anonymous: boolean) {\n    try {\n      const response = await this.client.post<SubmitBenchmarkResponse>('/benchmark/submit', { benchmark_id, anonymous })\n      return response.data\n    } catch (error: any) {\n      // For 409 Conflict errors, throw a specific error that the UI can handle\n      if (error.response?.status === 409) {\n        const err = new Error(error.response?.data?.error || 'This benchmark has already been submitted to the repository')\n          ; (err as any).status = 409\n        throw err\n      }\n      // For other errors, extract the message and throw\n      const errorMessage = error.response?.data?.error || error.message || 'Failed to submit benchmark'\n      throw new Error(errorMessage)\n    }\n  }\n\n  async subscribeToReleaseNotes(email: string) {\n    return catchInternal(async () => {\n      const response = await this.client.post<{ success: boolean; message: string }>(\n        '/system/subscribe-release-notes',\n        { email }\n      )\n      return response.data\n    })()\n  }\n\n  async syncRAGStorage() {\n    return catchInternal(async () => {\n      const response = await this.client.post<{\n        success: boolean\n        message: string\n        filesScanned?: number\n        filesQueued?: number\n      }>('/rag/sync')\n      return response.data\n    })()\n  }\n\n  // Wikipedia selector methods\n\n  async getWikipediaState(): Promise<WikipediaState | undefined> {\n    return catchInternal(async () => {\n      const response = await this.client.get<WikipediaState>('/zim/wikipedia')\n      return response.data\n    })()\n  }\n\n  async selectWikipedia(\n    optionId: string\n  ): Promise<{ success: boolean; jobId?: string; message?: string } | undefined> {\n    return catchInternal(async () => {\n      const response = await this.client.post<{\n        success: boolean\n        jobId?: string\n        message?: string\n      }>('/zim/wikipedia/select', { optionId })\n      return response.data\n    })()\n  }\n\n  async updateBuilderTag(benchmark_id: string, builder_tag: string) {\n    return catchInternal(async () => {\n      const response = await this.client.post<UpdateBuilderTagResponse>(\n        '/benchmark/builder-tag',\n        { benchmark_id, builder_tag }\n      )\n      return response.data\n    })()\n  }\n\n  async uploadDocument(file: File) {\n    return catchInternal(async () => {\n      const formData = new FormData()\n      formData.append('file', file)\n      const response = await this.client.post<{ message: string; file_path: string }>(\n        '/rag/upload',\n        formData,\n        {\n          headers: {\n            'Content-Type': 'multipart/form-data',\n          },\n        }\n      )\n      return response.data\n    })()\n  }\n\n  async getSetting(key: string) {\n    return catchInternal(async () => {\n      const response = await this.client.get<{ key: string; value: any }>(\n        '/system/settings',\n        { params: { key } }\n      )\n      return response.data\n    })()\n  }\n\n  async updateSetting(key: string, value: any) {\n    return catchInternal(async () => {\n      const response = await this.client.patch<{ success: boolean; message: string }>(\n        '/system/settings',\n        { key, value }\n      )\n      return response.data\n    })()\n  }\n}\n\nexport default new API()\n"
  },
  {
    "path": "admin/inertia/lib/builderTagWords.ts",
    "content": "// Builder Tag word lists for generating unique, NOMAD-themed identifiers\n// Format: [Adjective]-[Noun]-[4-digit number]\n\nexport const ADJECTIVES = [\n  'Tactical',\n  'Stealth',\n  'Rogue',\n  'Shadow',\n  'Ghost',\n  'Silent',\n  'Covert',\n  'Lone',\n  'Nomad',\n  'Digital',\n  'Cyber',\n  'Off-Grid',\n  'Remote',\n  'Arctic',\n  'Desert',\n  'Mountain',\n  'Urban',\n  'Bunker',\n  'Hidden',\n  'Secure',\n  'Armored',\n  'Fortified',\n  'Mobile',\n  'Solar',\n  'Nuclear',\n  'Storm',\n  'Thunder',\n  'Iron',\n  'Steel',\n  'Titanium',\n  'Carbon',\n  'Quantum',\n  'Neural',\n  'Alpha',\n  'Omega',\n  'Delta',\n  'Sigma',\n  'Apex',\n  'Prime',\n  'Elite',\n  'Midnight',\n  'Dawn',\n  'Dusk',\n  'Feral',\n  'Relic',\n  'Analog',\n  'Hardened',\n  'Vigilant',\n  'Outland',\n  'Frontier',\n] as const\n\nexport const NOUNS = [\n  'Llama',\n  'Wolf',\n  'Bear',\n  'Eagle',\n  'Falcon',\n  'Hawk',\n  'Raven',\n  'Fox',\n  'Coyote',\n  'Panther',\n  'Cobra',\n  'Viper',\n  'Phoenix',\n  'Dragon',\n  'Sentinel',\n  'Guardian',\n  'Ranger',\n  'Scout',\n  'Survivor',\n  'Prepper',\n  'Nomad',\n  'Wanderer',\n  'Drifter',\n  'Outpost',\n  'Shelter',\n  'Bunker',\n  'Vault',\n  'Cache',\n  'Haven',\n  'Fortress',\n  'Citadel',\n  'Node',\n  'Hub',\n  'Grid',\n  'Network',\n  'Signal',\n  'Beacon',\n  'Tower',\n  'Server',\n  'Cluster',\n  'Array',\n  'Matrix',\n  'Core',\n  'Nexus',\n  'Archive',\n  'Relay',\n  'Silo',\n  'Depot',\n  'Bastion',\n  'Homestead',\n] as const\n\nexport type Adjective = (typeof ADJECTIVES)[number]\nexport type Noun = (typeof NOUNS)[number]\n\nexport function generateRandomNumber(): string {\n  return String(Math.floor(Math.random() * 10000)).padStart(4, '0')\n}\n\nexport function generateRandomBuilderTag(): string {\n  const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]\n  const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]\n  const number = generateRandomNumber()\n  return `${adjective}-${noun}-${number}`\n}\n\nexport function parseBuilderTag(tag: string): {\n  adjective: Adjective\n  noun: Noun\n  number: string\n} | null {\n  const match = tag.match(/^(.+)-(.+)-(\\d{4})$/)\n  if (!match) return null\n\n  const [, adjective, noun, number] = match\n  if (!ADJECTIVES.includes(adjective as Adjective)) return null\n  if (!NOUNS.includes(noun as Noun)) return null\n\n  return {\n    adjective: adjective as Adjective,\n    noun: noun as Noun,\n    number,\n  }\n}\n\nexport function buildBuilderTag(adjective: string, noun: string, number: string): string {\n  return `${adjective}-${noun}-${number}`\n}\n"
  },
  {
    "path": "admin/inertia/lib/classNames.ts",
    "content": "\nexport default function classNames(...classes: (string | undefined)[]): string {\n  return classes.filter(Boolean).join(' ');\n}"
  },
  {
    "path": "admin/inertia/lib/collections.ts",
    "content": "import type { SpecResource, SpecTier } from '../../types/collections'\n\n/**\n * Resolve all resources for a tier, including inherited resources from includesTier chain.\n * Shared between frontend components (TierSelectionModal, CategoryCard, EasySetup).\n */\nexport function resolveTierResources(tier: SpecTier, allTiers: SpecTier[]): SpecResource[] {\n  const visited = new Set<string>()\n  return resolveTierResourcesInner(tier, allTiers, visited)\n}\n\nfunction resolveTierResourcesInner(\n  tier: SpecTier,\n  allTiers: SpecTier[],\n  visited: Set<string>\n): SpecResource[] {\n  if (visited.has(tier.slug)) return [] // cycle detection\n  visited.add(tier.slug)\n\n  const resources: SpecResource[] = []\n\n  if (tier.includesTier) {\n    const included = allTiers.find((t) => t.slug === tier.includesTier)\n    if (included) {\n      resources.push(...resolveTierResourcesInner(included, allTiers, visited))\n    }\n  }\n\n  resources.push(...tier.resources)\n  return resources\n}\n"
  },
  {
    "path": "admin/inertia/lib/navigation.ts",
    "content": "\n\nexport function getServiceLink(ui_location: string): string {\n    // Check if the ui location is a valid URL\n    try {\n        const url = new URL(ui_location);\n        // If it is a valid URL, return it as is\n        return url.href;\n    } catch (e) {\n        // If it fails, it means it's not a valid URL\n    }\n\n    // Check if the ui location is a port number\n    const parsedPort = parseInt(ui_location, 10);\n    if (!isNaN(parsedPort)) {\n        // If it's a port number, return a link to the service on that port\n        return `http://${window.location.hostname}:${parsedPort}`;\n    }\n\n    const pathPattern = /^\\/.+/;\n    if (pathPattern.test(ui_location)) {\n        // If it starts with a slash, treat it as a full path\n        return ui_location;\n    }\n\n    return `/${ui_location}`;\n}"
  },
  {
    "path": "admin/inertia/lib/util.ts",
    "content": "import { Notification } from \"~/context/NotificationContext\"\n\n// Global notification callback that can be set by the NotificationProvider\nlet globalNotificationCallback: ((notification: Notification) => void) | null = null\n\nexport function setGlobalNotificationCallback(callback: (notification: Notification) => void) {\n  globalNotificationCallback = callback\n}\n\nexport function capitalizeFirstLetter(str?: string | null): string {\n  if (!str) return ''\n  return str.charAt(0).toUpperCase() + str.slice(1)\n}\n\nexport function formatBytes(bytes: number, decimals = 2): string {\n  if (bytes === 0) return '0 Bytes'\n  const k = 1024\n  const dm = decimals < 0 ? 0 : decimals\n  const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']\n  const i = Math.floor(Math.log(bytes) / Math.log(k))\n  return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]\n}\n\nexport function generateRandomString(length: number): string {\n  const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'\n  let result = ''\n  for (let i = 0; i < length; i++) {\n    result += characters.charAt(Math.floor(Math.random() * characters.length))\n  }\n  return result\n}\n\nexport function generateUUID(): string {\n  const arr = new Uint8Array(16)\n  if (window.crypto && window.crypto.getRandomValues) {\n    window.crypto.getRandomValues(arr)\n  } else {\n    // Fallback for non-secure contexts where window.crypto is not available\n    // This is not cryptographically secure, but can be used for non-critical purposes\n    for (let i = 0; i < 16; i++) {\n      arr[i] = Math.floor(Math.random() * 256)\n    }\n  }\n\n  arr[6] = (arr[6] & 0x0f) | 0x40 // Version 4\n  arr[8] = (arr[8] & 0x3f) | 0x80 // Variant bits\n\n  const hex = Array.from(arr, (byte) => byte.toString(16).padStart(2, '0')).join('')\n  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`\n}\n\n/**\n * Extracts the file name from a given path while handling both forward and backward slashes.\n * @param path The full file path.\n * @returns The extracted file name.\n */\nexport const extractFileName = (path: string) => {\n  if (!path) return ''\n  if (path.includes('/')) {\n    return path.substring(path.lastIndexOf('/') + 1)\n  }\n  if (path.includes('\\\\')) {\n    return path.substring(path.lastIndexOf('\\\\') + 1)\n  }\n  return path\n}\n\n/**\n * A higher-order function that wraps an asynchronous function to catch and log internal errors.\n * @param fn The asynchronous function to be wrapped.\n * @returns A new function that executes the original function and logs any errors. Returns undefined in case of an error.\n */\nexport function catchInternal<Fn extends (...args: any[]) => any>(fn: Fn): (...args: Parameters<Fn>) => Promise<ReturnType<Fn> | undefined> {\n  return async (...args: any[]) => {\n    try {\n      return await fn(...args)\n    } catch (error) {\n      console.error('Internal error caught:', error)\n\n      if (globalNotificationCallback) {\n        const errorMessage = 'An internal error occurred. Please try again or check the console for details. ' + (error instanceof Error ? String(error.message).slice(0, 50) : '')\n        globalNotificationCallback({\n          message: errorMessage,\n          type: 'error',\n          duration: 5000\n        })\n      }\n\n      return undefined\n    }\n  }\n}"
  },
  {
    "path": "admin/inertia/pages/about.tsx",
    "content": "import AppLayout from '~/layouts/AppLayout'\n\nexport default function About() {\n  return (\n    <AppLayout>\n      <div className=\"p-2\">Hello from About!</div>\n    </AppLayout>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/pages/chat.tsx",
    "content": "import { Head, usePage } from '@inertiajs/react'\nimport ChatComponent from '~/components/chat'\n\nexport default function Chat(props: { settings: { chatSuggestionsEnabled: boolean } }) {\n  const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props\n  return (\n    <div className=\"w-full h-full\">\n      <Head title={aiAssistantName} />\n      <ChatComponent enabled={true} suggestionsEnabled={props.settings.chatSuggestionsEnabled} />\n    </div>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/pages/docs/show.tsx",
    "content": "import { Head } from '@inertiajs/react'\nimport MarkdocRenderer from '~/components/MarkdocRenderer'\nimport DocsLayout from '~/layouts/DocsLayout'\n\nexport default function Show({ content }: { content: any; }) {\n  return (\n    <DocsLayout>\n      <Head title={'Documentation'} />\n      <div className=\"xl:pl-80 pt-14 xl:pt-8 pb-8 px-6 sm:px-8 lg:px-12\">\n        <div className=\"max-w-4xl\">\n          <MarkdocRenderer content={content} />\n        </div>\n      </div>\n    </DocsLayout>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/pages/easy-setup/complete.tsx",
    "content": "import { Head, router } from '@inertiajs/react'\nimport AppLayout from '~/layouts/AppLayout'\nimport StyledButton from '~/components/StyledButton'\nimport Alert from '~/components/Alert'\nimport useInternetStatus from '~/hooks/useInternetStatus'\nimport useServiceInstallationActivity from '~/hooks/useServiceInstallationActivity'\nimport InstallActivityFeed from '~/components/InstallActivityFeed'\nimport ActiveDownloads from '~/components/ActiveDownloads'\nimport StyledSectionHeader from '~/components/StyledSectionHeader'\n\nexport default function EasySetupWizardComplete() {\n  const { isOnline } = useInternetStatus()\n  const installActivity = useServiceInstallationActivity()\n\n  return (\n    <AppLayout>\n      <Head title=\"Easy Setup Wizard Complete\" />\n      {!isOnline && (\n        <Alert\n          title=\"No Internet Connection\"\n          message=\"It looks like you're not connected to the internet. Installing apps and downloading content will require an internet connection.\"\n          type=\"warning\"\n          variant=\"solid\"\n          className=\"mb-8\"\n        />\n      )}\n      <div className=\"max-w-7xl mx-auto px-4 py-8\">\n        <div className=\"bg-surface-primary rounded-md shadow-md p-6\">\n          <StyledSectionHeader title=\"App Installation Activity\" className=\" mb-4\" />\n          <InstallActivityFeed\n            activity={installActivity}\n            className=\"!shadow-none border-desert-stone-light border\"\n          />\n          <ActiveDownloads withHeader />\n          <Alert\n            title=\"Running in the Background\"\n            message='Feel free to leave this page at any time - your app installs and downloads will continue in the background! Please note, the Information Library (if installed) may be unavailable until all initial downloads complete.'\n            type=\"info\"\n            variant=\"solid\"\n            className='mt-12'\n          />\n          <div className=\"flex justify-center mt-8 pt-4 border-t border-desert-stone-light\">\n            <div className=\"flex space-x-4\">\n              <StyledButton onClick={() => router.visit('/home')} icon=\"IconHome\">\n                Go to Home\n              </StyledButton>\n            </div>\n          </div>\n        </div>\n      </div>\n    </AppLayout>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/pages/easy-setup/index.tsx",
    "content": "import { Head, router, usePage } from '@inertiajs/react'\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\nimport { useEffect, useState, useMemo } from 'react'\nimport AppLayout from '~/layouts/AppLayout'\nimport StyledButton from '~/components/StyledButton'\nimport api from '~/lib/api'\nimport { ServiceSlim } from '../../../types/services'\nimport CuratedCollectionCard from '~/components/CuratedCollectionCard'\nimport CategoryCard from '~/components/CategoryCard'\nimport TierSelectionModal from '~/components/TierSelectionModal'\nimport WikipediaSelector from '~/components/WikipediaSelector'\nimport LoadingSpinner from '~/components/LoadingSpinner'\nimport Alert from '~/components/Alert'\nimport { IconCheck, IconChevronDown, IconChevronUp, IconCpu, IconBooks } from '@tabler/icons-react'\nimport StorageProjectionBar from '~/components/StorageProjectionBar'\nimport { useNotifications } from '~/context/NotificationContext'\nimport useInternetStatus from '~/hooks/useInternetStatus'\nimport { useSystemInfo } from '~/hooks/useSystemInfo'\nimport { getPrimaryDiskInfo } from '~/hooks/useDiskDisplayData'\nimport classNames from 'classnames'\nimport type { CategoryWithStatus, SpecTier, SpecResource } from '../../../types/collections'\nimport { resolveTierResources } from '~/lib/collections'\nimport { SERVICE_NAMES } from '../../../constants/service_names'\n\n// Capability definitions - maps user-friendly categories to services\ninterface Capability {\n  id: string\n  name: string\n  technicalName: string\n  description: string\n  features: string[]\n  services: string[] // service_name values that this capability installs\n  icon: string\n}\n\nfunction buildCoreCapabilities(aiAssistantName: string): Capability[] {\n  return [\n    {\n      id: 'information',\n      name: 'Information Library',\n      technicalName: 'Kiwix',\n      description:\n        'Offline access to Wikipedia, medical references, how-to guides, and encyclopedias',\n      features: [\n        'Complete Wikipedia offline',\n        'Medical references and first aid guides',\n        'DIY repair guides and how-to content',\n        'Project Gutenberg books and literature',\n      ],\n      services: [SERVICE_NAMES.KIWIX],\n      icon: 'IconBooks',\n    },\n    {\n      id: 'education',\n      name: 'Education Platform',\n      technicalName: 'Kolibri',\n      description: 'Interactive learning platform with video courses and exercises',\n      features: [\n        'Khan Academy math and science courses',\n        'K-12 curriculum content',\n        'Interactive exercises and quizzes',\n        'Progress tracking for learners',\n      ],\n      services: [SERVICE_NAMES.KOLIBRI],\n      icon: 'IconSchool',\n    },\n    {\n      id: 'ai',\n      name: aiAssistantName,\n      technicalName: 'Ollama',\n      description: 'Local AI chat that runs entirely on your hardware - no internet required',\n      features: [\n        'Private conversations that never leave your device',\n        'No internet connection needed after setup',\n        'Ask questions, get help with writing, brainstorm ideas',\n        'Runs on your own hardware with local AI models',\n      ],\n      services: [SERVICE_NAMES.OLLAMA],\n      icon: 'IconRobot',\n    },\n  ]\n}\n\nconst ADDITIONAL_TOOLS: Capability[] = [\n  {\n    id: 'notes',\n    name: 'Notes',\n    technicalName: 'FlatNotes',\n    description: 'Simple note-taking app with local storage',\n    features: ['Markdown support', 'All notes stored locally', 'No account required'],\n    services: [SERVICE_NAMES.FLATNOTES],\n    icon: 'IconNotes',\n  },\n  {\n    id: 'datatools',\n    name: 'Data Tools',\n    technicalName: 'CyberChef',\n    description: 'Swiss Army knife for data encoding, encryption, and analysis',\n    features: [\n      'Encode/decode data (Base64, hex, etc.)',\n      'Encryption and hashing tools',\n      'Data format conversion',\n    ],\n    services: [SERVICE_NAMES.CYBERCHEF],\n    icon: 'IconChefHat',\n  },\n]\n\ntype WizardStep = 1 | 2 | 3 | 4\n\nconst CURATED_MAP_COLLECTIONS_KEY = 'curated-map-collections'\nconst CURATED_CATEGORIES_KEY = 'curated-categories'\nconst WIKIPEDIA_STATE_KEY = 'wikipedia-state'\n\nexport default function EasySetupWizard(props: { system: { services: ServiceSlim[] } }) {\n  const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props\n  const CORE_CAPABILITIES = buildCoreCapabilities(aiAssistantName)\n\n  const [currentStep, setCurrentStep] = useState<WizardStep>(1)\n  const [selectedServices, setSelectedServices] = useState<string[]>([])\n  const [selectedMapCollections, setSelectedMapCollections] = useState<string[]>([])\n  const [selectedAiModels, setSelectedAiModels] = useState<string[]>([])\n  const [isProcessing, setIsProcessing] = useState(false)\n  const [showAdditionalTools, setShowAdditionalTools] = useState(false)\n\n  // Category/tier selection state\n  const [selectedTiers, setSelectedTiers] = useState<Map<string, SpecTier>>(new Map())\n  const [tierModalOpen, setTierModalOpen] = useState(false)\n  const [activeCategory, setActiveCategory] = useState<CategoryWithStatus | null>(null)\n\n  // Wikipedia selection state\n  const [selectedWikipedia, setSelectedWikipedia] = useState<string | null>(null)\n\n  const { addNotification } = useNotifications()\n  const { isOnline } = useInternetStatus()\n  const queryClient = useQueryClient()\n  const { data: systemInfo } = useSystemInfo({ enabled: true })\n\n  const anySelectionMade =\n    selectedServices.length > 0 ||\n    selectedMapCollections.length > 0 ||\n    selectedTiers.size > 0 ||\n    selectedAiModels.length > 0 ||\n    (selectedWikipedia !== null && selectedWikipedia !== 'none')\n\n  const { data: mapCollections, isLoading: isLoadingMaps } = useQuery({\n    queryKey: [CURATED_MAP_COLLECTIONS_KEY],\n    queryFn: () => api.listCuratedMapCollections(),\n    refetchOnWindowFocus: false,\n  })\n\n  // Fetch curated categories with tiers\n  const { data: categories, isLoading: isLoadingCategories } = useQuery({\n    queryKey: [CURATED_CATEGORIES_KEY],\n    queryFn: () => api.listCuratedCategories(),\n    refetchOnWindowFocus: false,\n  })\n\n  const { data: recommendedModels, isLoading: isLoadingRecommendedModels } = useQuery({\n    queryKey: ['recommended-ollama-models'],\n    queryFn: async () => {\n      const res = await api.getAvailableModels({ recommendedOnly: true })\n      if (!res) {\n        return []\n      }\n      return res.models\n    },\n    refetchOnWindowFocus: false,\n  })\n\n  // Fetch Wikipedia options and current state\n  const { data: wikipediaState, isLoading: isLoadingWikipedia } = useQuery({\n    queryKey: [WIKIPEDIA_STATE_KEY],\n    queryFn: () => api.getWikipediaState(),\n    refetchOnWindowFocus: false,\n  })\n\n  // All services for display purposes\n  const allServices = props.system.services\n\n  const availableServices = props.system.services.filter(\n    (service) => !service.installed && service.installation_status !== 'installing'\n  )\n\n  // Services that are already installed\n  const installedServices = props.system.services.filter((service) => service.installed)\n\n  const toggleMapCollection = (slug: string) => {\n    setSelectedMapCollections((prev) =>\n      prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug]\n    )\n  }\n\n  const toggleAiModel = (modelName: string) => {\n    setSelectedAiModels((prev) =>\n      prev.includes(modelName) ? prev.filter((m) => m !== modelName) : [...prev, modelName]\n    )\n  }\n\n  // Category/tier handlers\n  const handleCategoryClick = (category: CategoryWithStatus) => {\n    if (!isOnline) return\n    setActiveCategory(category)\n    setTierModalOpen(true)\n  }\n\n  const handleTierSelect = (category: CategoryWithStatus, tier: SpecTier) => {\n    setSelectedTiers((prev) => {\n      const newMap = new Map(prev)\n      // If same tier is selected, deselect it\n      if (prev.get(category.slug)?.slug === tier.slug) {\n        newMap.delete(category.slug)\n      } else {\n        newMap.set(category.slug, tier)\n      }\n      return newMap\n    })\n  }\n\n  const closeTierModal = () => {\n    setTierModalOpen(false)\n    setActiveCategory(null)\n  }\n\n  // Get all resources from selected tiers for storage projection\n  const getSelectedTierResources = (): SpecResource[] => {\n    if (!categories) return []\n    const resources: SpecResource[] = []\n    selectedTiers.forEach((tier, categorySlug) => {\n      const category = categories.find((c) => c.slug === categorySlug)\n      if (category) {\n        resources.push(...resolveTierResources(tier, category.tiers))\n      }\n    })\n    return resources\n  }\n\n  // Calculate total projected storage from all selections\n  const projectedStorageBytes = useMemo(() => {\n    let totalBytes = 0\n\n    // Add tier resources\n    const tierResources = getSelectedTierResources()\n    totalBytes += tierResources.reduce((sum, r) => sum + (r.size_mb ?? 0) * 1024 * 1024, 0)\n\n    // Add map collections\n    if (mapCollections) {\n      selectedMapCollections.forEach((slug) => {\n        const collection = mapCollections.find((c) => c.slug === slug)\n        if (collection) {\n          totalBytes += collection.resources.reduce((sum, r) => sum + r.size_mb * 1024 * 1024, 0)\n        }\n      })\n    }\n\n    // Add AI models\n    if (recommendedModels) {\n      selectedAiModels.forEach((modelName) => {\n        const model = recommendedModels.find((m) => m.name === modelName)\n        if (model?.tags?.[0]?.size) {\n          // Parse size string like \"4.7GB\" or \"1.5GB\"\n          const sizeStr = model.tags[0].size\n          const match = sizeStr.match(/^([\\d.]+)\\s*(GB|MB|KB)?$/i)\n          if (match) {\n            const value = parseFloat(match[1])\n            const unit = (match[2] || 'GB').toUpperCase()\n            if (unit === 'GB') {\n              totalBytes += value * 1024 * 1024 * 1024\n            } else if (unit === 'MB') {\n              totalBytes += value * 1024 * 1024\n            } else if (unit === 'KB') {\n              totalBytes += value * 1024\n            }\n          }\n        }\n      })\n    }\n\n    // Add Wikipedia selection\n    if (selectedWikipedia && wikipediaState) {\n      const option = wikipediaState.options.find((o) => o.id === selectedWikipedia)\n      if (option && option.size_mb > 0) {\n        totalBytes += option.size_mb * 1024 * 1024\n      }\n    }\n\n    return totalBytes\n  }, [\n    selectedTiers,\n    selectedMapCollections,\n    selectedAiModels,\n    selectedWikipedia,\n    categories,\n    mapCollections,\n    recommendedModels,\n    wikipediaState,\n  ])\n\n  // Get primary disk/filesystem info for storage projection\n  const storageInfo = getPrimaryDiskInfo(systemInfo?.disk, systemInfo?.fsSize)\n\n  const canProceedToNextStep = () => {\n    if (!isOnline) return false // Must be online to proceed\n    if (currentStep === 1) return true // Can skip app installation\n    if (currentStep === 2) return true // Can skip map downloads\n    if (currentStep === 3) return true // Can skip ZIM downloads\n    return false\n  }\n\n  const handleNext = () => {\n    if (currentStep < 4) {\n      setCurrentStep((prev) => (prev + 1) as WizardStep)\n    }\n  }\n\n  const handleBack = () => {\n    if (currentStep > 1) {\n      setCurrentStep((prev) => (prev - 1) as WizardStep)\n    }\n  }\n\n  const handleFinish = async () => {\n    if (!isOnline) {\n      addNotification({\n        type: 'error',\n        message: 'You must have an internet connection to complete the setup.',\n      })\n      return\n    }\n\n    setIsProcessing(true)\n\n    try {\n      // All of these ops don't actually wait for completion, they just kick off the process, so we can run them in parallel without awaiting each one sequentially\n      const installPromises = selectedServices.map((serviceName) => api.installService(serviceName))\n\n      await Promise.all(installPromises)\n\n      // Download collections, category tiers, and AI models\n      const categoryTierPromises: Promise<any>[] = []\n      selectedTiers.forEach((tier, categorySlug) => {\n        categoryTierPromises.push(api.downloadCategoryTier(categorySlug, tier.slug))\n      })\n\n      const downloadPromises = [\n        ...selectedMapCollections.map((slug) => api.downloadMapCollection(slug)),\n        ...categoryTierPromises,\n        ...selectedAiModels.map((modelName) => api.downloadModel(modelName)),\n      ]\n\n      await Promise.all(downloadPromises)\n\n      // Select Wikipedia option if one was chosen\n      if (selectedWikipedia && selectedWikipedia !== wikipediaState?.currentSelection?.optionId) {\n        await api.selectWikipedia(selectedWikipedia)\n      }\n\n      addNotification({\n        type: 'success',\n        message: 'Setup wizard completed! Your selections are being processed.',\n      })\n\n      router.visit('/easy-setup/complete')\n    } catch (error) {\n      console.error('Error during setup:', error)\n      addNotification({\n        type: 'error',\n        message: 'An error occurred during setup. Some items may not have been processed.',\n      })\n    } finally {\n      setIsProcessing(false)\n    }\n  }\n\n  const refreshManifests = useMutation({\n    mutationFn: () => api.refreshManifests(),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: [CURATED_MAP_COLLECTIONS_KEY] })\n      queryClient.invalidateQueries({ queryKey: [CURATED_CATEGORIES_KEY] })\n    },\n  })\n\n  // Scroll to top when step changes\n  useEffect(() => {\n    window.scrollTo({ top: 0, behavior: 'smooth' })\n  }, [currentStep])\n\n  // Refresh manifests on mount to ensure we have latest data\n  useEffect(() => {\n    if (!refreshManifests.isPending) {\n      refreshManifests.mutate()\n    }\n  }, []) // eslint-disable-line react-hooks/exhaustive-deps\n\n  // Set Easy Setup as visited when user lands on this page\n  useEffect(() => {\n    const markAsVisited = async () => {\n      try {\n        await api.updateSetting('ui.hasVisitedEasySetup', 'true')\n      } catch (error) {\n        // Silent fail - this is non-critical\n        console.warn('Failed to mark Easy Setup as visited:', error)\n      }\n    }\n\n    markAsVisited()\n  }, [])\n\n  const renderStepIndicator = () => {\n    const steps = [\n      { number: 1, label: 'Apps' },\n      { number: 2, label: 'Maps' },\n      { number: 3, label: 'Content' },\n      { number: 4, label: 'Review' },\n    ]\n\n    return (\n      <nav aria-label=\"Progress\" className=\"px-6 pt-6\">\n        <ol\n          role=\"list\"\n          className=\"divide-y divide-border-default rounded-md md:flex md:divide-y-0 md:justify-between border border-desert-green\"\n        >\n          {steps.map((step, stepIdx) => (\n            <li key={step.number} className=\"relative md:flex-1 md:flex md:justify-center\">\n              {currentStep > step.number ? (\n                <div className=\"group flex w-full items-center md:justify-center\">\n                  <span className=\"flex items-center px-6 py-2 text-sm font-medium\">\n                    <span className=\"flex size-10 shrink-0 items-center justify-center rounded-full bg-desert-green\">\n                      <IconCheck aria-hidden=\"true\" className=\"size-6 text-white\" />\n                    </span>\n                    <span className=\"ml-4 text-lg font-medium text-text-primary\">{step.label}</span>\n                  </span>\n                </div>\n              ) : currentStep === step.number ? (\n                <div\n                  aria-current=\"step\"\n                  className=\"flex items-center px-6 py-2 text-sm font-medium md:justify-center\"\n                >\n                  <span className=\"flex size-10 shrink-0 items-center justify-center rounded-full bg-desert-green border-2 border-desert-green\">\n                    <span className=\"text-white\">{step.number}</span>\n                  </span>\n                  <span className=\"ml-4 text-lg font-medium text-desert-green\">{step.label}</span>\n                </div>\n              ) : (\n                <div className=\"group flex items-center md:justify-center\">\n                  <span className=\"flex items-center px-6 py-2 text-sm font-medium\">\n                    <span className=\"flex size-10 shrink-0 items-center justify-center rounded-full border-2 border-border-default\">\n                      <span className=\"text-text-muted\">{step.number}</span>\n                    </span>\n                    <span className=\"ml-4 text-lg font-medium text-text-muted\">{step.label}</span>\n                  </span>\n                </div>\n              )}\n\n              {stepIdx !== steps.length - 1 ? (\n                <>\n                  {/* Arrow separator for lg screens and up */}\n                  <div\n                    aria-hidden=\"true\"\n                    className=\"absolute top-0 right-0 hidden h-full w-5 md:block\"\n                  >\n                    <svg\n                      fill=\"none\"\n                      viewBox=\"0 0 22 80\"\n                      preserveAspectRatio=\"none\"\n                      className={`size-full ${currentStep > step.number ? 'text-desert-green' : 'text-text-muted'}`}\n                    >\n                      <path\n                        d=\"M0 -2L20 40L0 82\"\n                        stroke=\"currentcolor\"\n                        vectorEffect=\"non-scaling-stroke\"\n                        strokeLinejoin=\"round\"\n                      />\n                    </svg>\n                  </div>\n                </>\n              ) : null}\n            </li>\n          ))}\n        </ol>\n      </nav>\n    )\n  }\n\n  // Check if a capability is selected (all its services are in selectedServices)\n  const isCapabilitySelected = (capability: Capability) => {\n    return capability.services.every((service) => selectedServices.includes(service))\n  }\n\n  // Check if a capability is already installed (all its services are installed)\n  const isCapabilityInstalled = (capability: Capability) => {\n    return capability.services.every((service) =>\n      installedServices.some((s) => s.service_name === service)\n    )\n  }\n\n  // Check if a capability exists in the system (has at least one matching service)\n  const capabilityExists = (capability: Capability) => {\n    return capability.services.some((service) =>\n      allServices.some((s) => s.service_name === service)\n    )\n  }\n\n  // Toggle all services for a capability (only if not already installed)\n  const toggleCapability = (capability: Capability) => {\n    // Don't allow toggling installed capabilities\n    if (isCapabilityInstalled(capability)) return\n\n    const isSelected = isCapabilitySelected(capability)\n    if (isSelected) {\n      // Deselect all services in this capability\n      setSelectedServices((prev) => prev.filter((s) => !capability.services.includes(s)))\n    } else {\n      // Select all available services in this capability\n      const servicesToAdd = capability.services.filter((service) =>\n        availableServices.some((s) => s.service_name === service)\n      )\n      setSelectedServices((prev) => [...new Set([...prev, ...servicesToAdd])])\n    }\n  }\n\n  const renderCapabilityCard = (capability: Capability, isCore: boolean = true) => {\n    const selected = isCapabilitySelected(capability)\n    const installed = isCapabilityInstalled(capability)\n    const exists = capabilityExists(capability)\n\n    if (!exists) return null\n\n    // Determine visual state: installed (locked), selected (user chose it), or default\n    const isChecked = installed || selected\n\n    return (\n      <div\n        key={capability.id}\n        onClick={() => toggleCapability(capability)}\n        className={classNames(\n          'p-6 rounded-lg border-2 transition-all',\n          installed\n            ? 'border-desert-green bg-desert-green/20 cursor-default'\n            : selected\n              ? 'border-desert-green bg-desert-green shadow-md cursor-pointer'\n              : 'border-desert-stone-light bg-surface-primary hover:border-desert-green hover:shadow-sm cursor-pointer'\n        )}\n      >\n        <div className=\"flex items-start justify-between\">\n          <div className=\"flex-1\">\n            <div className=\"flex items-center gap-2\">\n              <h3\n                className={classNames(\n                  'text-xl font-bold',\n                  installed ? 'text-text-primary' : selected ? 'text-white' : 'text-text-primary'\n                )}\n              >\n                {capability.name}\n              </h3>\n              {installed && (\n                <span className=\"text-xs bg-desert-green text-white px-2 py-0.5 rounded-full\">\n                  Installed\n                </span>\n              )}\n            </div>\n            <p\n              className={classNames(\n                'text-sm mt-0.5',\n                installed ? 'text-text-muted' : selected ? 'text-green-100' : 'text-text-muted'\n              )}\n            >\n              Powered by {capability.technicalName}\n            </p>\n            <p\n              className={classNames(\n                'text-sm mt-3',\n                installed ? 'text-text-secondary' : selected ? 'text-white' : 'text-text-secondary'\n              )}\n            >\n              {capability.description}\n            </p>\n            {isCore && (\n              <ul\n                className={classNames(\n                  'mt-3 space-y-1',\n                  installed ? 'text-text-secondary' : selected ? 'text-white' : 'text-text-secondary'\n                )}\n              >\n                {capability.features.map((feature, idx) => (\n                  <li key={idx} className=\"flex items-start text-sm\">\n                    <span\n                      className={classNames(\n                        'mr-2',\n                        installed\n                          ? 'text-desert-green'\n                          : selected\n                            ? 'text-white'\n                            : 'text-desert-green'\n                      )}\n                    >\n                      •\n                    </span>\n                    {feature}\n                  </li>\n                ))}\n              </ul>\n            )}\n          </div>\n          <div\n            className={classNames(\n              'ml-4 w-7 h-7 rounded-full border-2 flex items-center justify-center transition-all flex-shrink-0',\n              isChecked\n                ? installed\n                  ? 'border-desert-green bg-desert-green'\n                  : 'border-white bg-white'\n                : 'border-desert-stone'\n            )}\n          >\n            {isChecked && (\n              <IconCheck size={20} className={installed ? 'text-white' : 'text-desert-green'} />\n            )}\n          </div>\n        </div>\n      </div>\n    )\n  }\n\n  const renderStep1 = () => {\n    // Show all capabilities that exist in the system (including installed ones)\n    const existingCoreCapabilities = CORE_CAPABILITIES.filter(capabilityExists)\n    const existingAdditionalTools = ADDITIONAL_TOOLS.filter(capabilityExists)\n\n    // Check if ALL capabilities are already installed (nothing left to install)\n    const allCoreInstalled = existingCoreCapabilities.every(isCapabilityInstalled)\n    const allAdditionalInstalled = existingAdditionalTools.every(isCapabilityInstalled)\n    const allInstalled =\n      allCoreInstalled && allAdditionalInstalled && existingCoreCapabilities.length > 0\n\n    return (\n      <div className=\"space-y-8\">\n        <div className=\"text-center mb-6\">\n          <h2 className=\"text-3xl font-bold text-text-primary mb-2\">What do you want NOMAD to do?</h2>\n          <p className=\"text-text-secondary\">\n            Select the capabilities you need. You can always add more later.\n          </p>\n        </div>\n\n        {allInstalled ? (\n          <div className=\"text-center py-12\">\n            <p className=\"text-text-secondary text-lg\">\n              All available capabilities are already installed!\n            </p>\n            <StyledButton\n              variant=\"primary\"\n              className=\"mt-4\"\n              onClick={() => router.visit('/settings/apps')}\n            >\n              Manage Apps\n            </StyledButton>\n          </div>\n        ) : (\n          <>\n            {/* Core Capabilities */}\n            {existingCoreCapabilities.length > 0 && (\n              <div>\n                <h3 className=\"text-lg font-semibold text-text-primary mb-4\">Core Capabilities</h3>\n                <div className=\"grid grid-cols-1 lg:grid-cols-3 gap-4\">\n                  {existingCoreCapabilities.map((capability) =>\n                    renderCapabilityCard(capability, true)\n                  )}\n                </div>\n              </div>\n            )}\n\n            {/* Additional Tools - Collapsible */}\n            {existingAdditionalTools.length > 0 && (\n              <div className=\"border-t border-desert-stone-light pt-6\">\n                <button\n                  onClick={() => setShowAdditionalTools(!showAdditionalTools)}\n                  className=\"flex items-center justify-between w-full text-left\"\n                >\n                  <h3 className=\"text-md font-medium text-text-muted\">Additional Tools</h3>\n                  {showAdditionalTools ? (\n                    <IconChevronUp size={20} className=\"text-text-muted\" />\n                  ) : (\n                    <IconChevronDown size={20} className=\"text-text-muted\" />\n                  )}\n                </button>\n                {showAdditionalTools && (\n                  <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4 mt-4\">\n                    {existingAdditionalTools.map((capability) =>\n                      renderCapabilityCard(capability, false)\n                    )}\n                  </div>\n                )}\n              </div>\n            )}\n          </>\n        )}\n      </div>\n    )\n  }\n\n  const renderStep2 = () => (\n    <div className=\"space-y-6\">\n      <div className=\"text-center mb-6\">\n        <h2 className=\"text-3xl font-bold text-text-primary mb-2\">Choose Map Regions</h2>\n        <p className=\"text-text-secondary\">\n          Select map region collections to download for offline use. You can always download more\n          regions later.\n        </p>\n      </div>\n      {isLoadingMaps ? (\n        <div className=\"flex justify-center py-12\">\n          <LoadingSpinner />\n        </div>\n      ) : mapCollections && mapCollections.length > 0 ? (\n        <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6\">\n          {mapCollections.map((collection) => (\n            <div\n              key={collection.slug}\n              onClick={() =>\n                isOnline && !collection.all_installed && toggleMapCollection(collection.slug)\n              }\n              className={classNames(\n                'relative',\n                selectedMapCollections.includes(collection.slug) &&\n                'ring-4 ring-desert-green rounded-lg',\n                collection.all_installed && 'opacity-75',\n                !isOnline && 'opacity-50 cursor-not-allowed'\n              )}\n            >\n              <CuratedCollectionCard collection={collection} />\n              {selectedMapCollections.includes(collection.slug) && (\n                <div className=\"absolute top-2 right-2 bg-desert-green rounded-full p-1\">\n                  <IconCheck size={32} className=\"text-white\" />\n                </div>\n              )}\n            </div>\n          ))}\n        </div>\n      ) : (\n        <div className=\"text-center py-12\">\n          <p className=\"text-text-secondary text-lg\">No map collections available at this time.</p>\n        </div>\n      )}\n    </div>\n  )\n\n  const renderStep3 = () => {\n    // Check if AI or Information capabilities are selected OR already installed\n    const isAiSelected = selectedServices.includes(SERVICE_NAMES.OLLAMA) ||\n      installedServices.some((s) => s.service_name === SERVICE_NAMES.OLLAMA)\n    const isInformationSelected = selectedServices.includes(SERVICE_NAMES.KIWIX) ||\n      installedServices.some((s) => s.service_name === SERVICE_NAMES.KIWIX)\n\n    return (\n      <div className=\"space-y-6\">\n        <div className=\"text-center mb-6\">\n          <h2 className=\"text-3xl font-bold text-text-primary mb-2\">Choose Content</h2>\n          <p className=\"text-text-secondary\">\n            {isAiSelected && isInformationSelected\n              ? 'Select AI models and content categories for offline use.'\n              : isAiSelected\n                ? 'Select AI models to download for offline use.'\n                : isInformationSelected\n                  ? 'Select content categories for offline knowledge.'\n                  : 'Configure content for your selected capabilities.'}\n          </p>\n        </div>\n\n        {/* AI Model Selection - Only show if AI capability is selected */}\n        {isAiSelected && (\n          <div className=\"mb-8\">\n            <div className=\"flex items-center gap-3 mb-4\">\n              <div className=\"w-10 h-10 rounded-full bg-surface-primary border border-border-subtle flex items-center justify-center shadow-sm\">\n                <IconCpu className=\"w-6 h-6 text-text-primary\" />\n              </div>\n              <div>\n                <h3 className=\"text-xl font-semibold text-text-primary\">AI Models</h3>\n                <p className=\"text-sm text-text-muted\">Select models to download for offline AI</p>\n              </div>\n            </div>\n\n            {isLoadingRecommendedModels ? (\n              <div className=\"flex justify-center py-12\">\n                <LoadingSpinner />\n              </div>\n            ) : recommendedModels && recommendedModels.length > 0 ? (\n              <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4\">\n                {recommendedModels.map((model) => (\n                  <div\n                    key={model.name}\n                    onClick={() => isOnline && toggleAiModel(model.name)}\n                    className={classNames(\n                      'p-4 rounded-lg border-2 transition-all cursor-pointer',\n                      selectedAiModels.includes(model.name)\n                        ? 'border-desert-green bg-desert-green shadow-md'\n                        : 'border-desert-stone-light bg-surface-primary hover:border-desert-green hover:shadow-sm',\n                      !isOnline && 'opacity-50 cursor-not-allowed'\n                    )}\n                  >\n                    <div className=\"flex items-start justify-between\">\n                      <div className=\"flex-1\">\n                        <h4\n                          className={classNames(\n                            'text-lg font-semibold mb-1',\n                            selectedAiModels.includes(model.name) ? 'text-white' : 'text-text-primary'\n                          )}\n                        >\n                          {model.name}\n                        </h4>\n                        <p\n                          className={classNames(\n                            'text-sm mb-2',\n                            selectedAiModels.includes(model.name) ? 'text-white' : 'text-text-secondary'\n                          )}\n                        >\n                          {model.description}\n                        </p>\n                        {model.tags?.[0]?.size && (\n                          <div\n                            className={classNames(\n                              'text-xs',\n                              selectedAiModels.includes(model.name)\n                                ? 'text-green-100'\n                                : 'text-text-muted'\n                            )}\n                          >\n                            Size: {model.tags[0].size}\n                          </div>\n                        )}\n                      </div>\n                      <div\n                        className={classNames(\n                          'ml-4 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all flex-shrink-0',\n                          selectedAiModels.includes(model.name)\n                            ? 'border-white bg-white'\n                            : 'border-desert-stone'\n                        )}\n                      >\n                        {selectedAiModels.includes(model.name) && (\n                          <IconCheck size={16} className=\"text-desert-green\" />\n                        )}\n                      </div>\n                    </div>\n                  </div>\n                ))}\n              </div>\n            ) : (\n              <div className=\"text-center py-8 bg-surface-secondary rounded-lg\">\n                <p className=\"text-text-secondary\">No recommended AI models available at this time.</p>\n              </div>\n            )}\n          </div>\n        )}\n\n        {/* Wikipedia Selection - Only show if Information capability is selected */}\n        {isInformationSelected && (\n          <>\n            {/* Divider between AI Models and Wikipedia */}\n            {isAiSelected && <hr className=\"my-8 border-border-subtle\" />}\n\n            <div className=\"mb-8\">\n              {isLoadingWikipedia ? (\n                <div className=\"flex justify-center py-12\">\n                  <LoadingSpinner />\n                </div>\n              ) : wikipediaState && wikipediaState.options.length > 0 ? (\n                <WikipediaSelector\n                  options={wikipediaState.options}\n                  currentSelection={wikipediaState.currentSelection}\n                  selectedOptionId={selectedWikipedia}\n                  onSelect={(optionId) => isOnline && setSelectedWikipedia(optionId)}\n                  disabled={!isOnline}\n                />\n              ) : null}\n            </div>\n          </>\n        )}\n\n        {/* Curated Categories with Tiers - Only show if Information capability is selected */}\n        {isInformationSelected && (\n          <>\n            {/* Divider between Wikipedia and Additional Content */}\n            <hr className=\"my-8 border-border-subtle\" />\n\n            <div className=\"flex items-center gap-3 mb-4\">\n              <div className=\"w-10 h-10 rounded-full bg-surface-primary border border-border-subtle flex items-center justify-center shadow-sm\">\n                <IconBooks className=\"w-6 h-6 text-text-primary\" />\n              </div>\n              <div>\n                <h3 className=\"text-xl font-semibold text-text-primary\">Additional Content</h3>\n                <p className=\"text-sm text-text-muted\">Curated collections for offline reference</p>\n              </div>\n            </div>\n\n            {isLoadingCategories ? (\n              <div className=\"flex justify-center py-12\">\n                <LoadingSpinner />\n              </div>\n            ) : categories && categories.length > 0 ? (\n              <>\n                <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6\">\n                  {categories.map((category) => (\n                    <CategoryCard\n                      key={category.slug}\n                      category={category}\n                      selectedTier={selectedTiers.get(category.slug) || null}\n                      onClick={handleCategoryClick}\n                    />\n                  ))}\n                </div>\n\n                {/* Tier Selection Modal */}\n                <TierSelectionModal\n                  isOpen={tierModalOpen}\n                  onClose={closeTierModal}\n                  category={activeCategory}\n                  selectedTierSlug={\n                    activeCategory\n                      ? selectedTiers.get(activeCategory.slug)?.slug || activeCategory.installedTierSlug\n                      : null\n                  }\n                  onSelectTier={handleTierSelect}\n                />\n              </>\n            ) : null}\n\n          </>\n        )}\n\n        {/* Show message if no capabilities requiring content are selected */}\n        {!isAiSelected && !isInformationSelected && (\n          <div className=\"text-center py-12\">\n            <p className=\"text-text-secondary text-lg\">\n              No content-based capabilities selected. You can skip this step or go back to select\n              capabilities that require content.\n            </p>\n          </div>\n        )}\n      </div>\n    )\n  }\n\n  const renderStep4 = () => {\n    const hasSelections =\n      selectedServices.length > 0 ||\n      selectedMapCollections.length > 0 ||\n      selectedTiers.size > 0 ||\n      selectedAiModels.length > 0 ||\n      (selectedWikipedia !== null && selectedWikipedia !== 'none')\n\n    return (\n      <div className=\"space-y-6\">\n        <div className=\"text-center mb-6\">\n          <h2 className=\"text-3xl font-bold text-text-primary mb-2\">Review Your Selections</h2>\n          <p className=\"text-text-secondary\">Review your choices before starting the setup process.</p>\n        </div>\n\n        {!hasSelections ? (\n          <Alert\n            title=\"No Selections Made\"\n            message=\"You haven't selected anything to install or download. You can go back to make selections or go back to the home page.\"\n            type=\"info\"\n            variant=\"bordered\"\n          />\n        ) : (\n          <div className=\"space-y-6\">\n            {selectedServices.length > 0 && (\n              <div className=\"bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6\">\n                <h3 className=\"text-xl font-semibold text-text-primary mb-4\">\n                  Capabilities to Install\n                </h3>\n                <ul className=\"space-y-2\">\n                  {[...CORE_CAPABILITIES, ...ADDITIONAL_TOOLS]\n                    .filter((cap) => cap.services.some((s) => selectedServices.includes(s)))\n                    .map((capability) => (\n                      <li key={capability.id} className=\"flex items-center\">\n                        <IconCheck size={20} className=\"text-desert-green mr-2\" />\n                        <span className=\"text-text-primary\">\n                          {capability.name}\n                          <span className=\"text-text-muted text-sm ml-2\">\n                            ({capability.technicalName})\n                          </span>\n                        </span>\n                      </li>\n                    ))}\n                </ul>\n              </div>\n            )}\n\n            {selectedMapCollections.length > 0 && (\n              <div className=\"bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6\">\n                <h3 className=\"text-xl font-semibold text-text-primary mb-4\">\n                  Map Collections to Download ({selectedMapCollections.length})\n                </h3>\n                <ul className=\"space-y-2\">\n                  {selectedMapCollections.map((slug) => {\n                    const collection = mapCollections?.find((c) => c.slug === slug)\n                    return (\n                      <li key={slug} className=\"flex items-center\">\n                        <IconCheck size={20} className=\"text-desert-green mr-2\" />\n                        <span className=\"text-text-primary\">{collection?.name || slug}</span>\n                      </li>\n                    )\n                  })}\n                </ul>\n              </div>\n            )}\n\n            {selectedTiers.size > 0 && (\n              <div className=\"bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6\">\n                <h3 className=\"text-xl font-semibold text-text-primary mb-4\">\n                  Content Categories ({selectedTiers.size})\n                </h3>\n                {Array.from(selectedTiers.entries()).map(([categorySlug, tier]) => {\n                  const category = categories?.find((c) => c.slug === categorySlug)\n                  if (!category) return null\n                  const resources = resolveTierResources(tier, category.tiers)\n                  return (\n                    <div key={categorySlug} className=\"mb-4 last:mb-0\">\n                      <div className=\"flex items-center mb-2\">\n                        <IconCheck size={20} className=\"text-desert-green mr-2\" />\n                        <span className=\"text-text-primary font-medium\">\n                          {category.name} - {tier.name}\n                        </span>\n                        <span className=\"text-text-muted text-sm ml-2\">\n                          ({resources.length} files)\n                        </span>\n                      </div>\n                      <ul className=\"ml-7 space-y-1\">\n                        {resources.map((resource, idx) => (\n                          <li key={idx} className=\"text-sm text-text-secondary\">\n                            {resource.title}\n                          </li>\n                        ))}\n                      </ul>\n                    </div>\n                  )\n                })}\n              </div>\n            )}\n\n            {selectedWikipedia && selectedWikipedia !== 'none' && (\n              <div className=\"bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6\">\n                <h3 className=\"text-xl font-semibold text-text-primary mb-4\">Wikipedia</h3>\n                {(() => {\n                  const option = wikipediaState?.options.find((o) => o.id === selectedWikipedia)\n                  return option ? (\n                    <div className=\"flex items-center justify-between\">\n                      <div className=\"flex items-center\">\n                        <IconCheck size={20} className=\"text-desert-green mr-2\" />\n                        <span className=\"text-text-primary\">{option.name}</span>\n                      </div>\n                      <span className=\"text-text-muted text-sm\">\n                        {option.size_mb > 0\n                          ? `${(option.size_mb / 1024).toFixed(1)} GB`\n                          : 'No download'}\n                      </span>\n                    </div>\n                  ) : null\n                })()}\n              </div>\n            )}\n\n            {selectedAiModels.length > 0 && (\n              <div className=\"bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6\">\n                <h3 className=\"text-xl font-semibold text-text-primary mb-4\">\n                  AI Models to Download ({selectedAiModels.length})\n                </h3>\n                <ul className=\"space-y-2\">\n                  {selectedAiModels.map((modelName) => {\n                    const model = recommendedModels?.find((m) => m.name === modelName)\n                    return (\n                      <li key={modelName} className=\"flex items-center justify-between\">\n                        <div className=\"flex items-center\">\n                          <IconCheck size={20} className=\"text-desert-green mr-2\" />\n                          <span className=\"text-text-primary\">{modelName}</span>\n                        </div>\n                        {model?.tags?.[0]?.size && (\n                          <span className=\"text-text-muted text-sm\">{model.tags[0].size}</span>\n                        )}\n                      </li>\n                    )\n                  })}\n                </ul>\n              </div>\n            )}\n\n            <Alert\n              title=\"Ready to Start\"\n              message=\"Click 'Complete Setup' to begin installing apps and downloading content. This may take some time depending on your internet connection and the size of the downloads.\"\n              type=\"info\"\n              variant=\"solid\"\n            />\n          </div>\n        )}\n      </div>\n    )\n  }\n\n  return (\n    <AppLayout>\n      <Head title=\"Easy Setup Wizard\" />\n      {!isOnline && (\n        <Alert\n          title=\"No Internet Connection\"\n          message=\"You'll need an internet connection to proceed. Please connect to the internet and try again.\"\n          type=\"warning\"\n          variant=\"solid\"\n          className=\"mb-8\"\n        />\n      )}\n      <div className=\"max-w-7xl mx-auto px-4 py-8\">\n        <div className=\"bg-surface-primary rounded-md shadow-md\">\n          {renderStepIndicator()}\n          {storageInfo && (\n            <div className=\"px-6 pt-4\">\n              <StorageProjectionBar\n                totalSize={storageInfo.totalSize}\n                currentUsed={storageInfo.totalUsed}\n                projectedAddition={projectedStorageBytes}\n              />\n            </div>\n          )}\n          <div className=\"p-6 min-h-fit\">\n            {currentStep === 1 && renderStep1()}\n            {currentStep === 2 && renderStep2()}\n            {currentStep === 3 && renderStep3()}\n            {currentStep === 4 && renderStep4()}\n\n            <div className=\"flex justify-between mt-8 pt-4 border-t border-desert-stone-light\">\n              <div className=\"flex space-x-4 items-center\">\n                {currentStep > 1 && (\n                  <StyledButton\n                    onClick={handleBack}\n                    disabled={isProcessing}\n                    variant=\"outline\"\n                    icon=\"IconChevronLeft\"\n                  >\n                    Back\n                  </StyledButton>\n                )}\n\n                <p className=\"text-sm text-text-secondary\">\n                  {(() => {\n                    const count = [...CORE_CAPABILITIES, ...ADDITIONAL_TOOLS].filter((cap) =>\n                      cap.services.some((s) => selectedServices.includes(s))\n                    ).length\n                    return `${count} ${count === 1 ? 'capability' : 'capabilities'}`\n                  })()}\n                  , {selectedMapCollections.length} map region\n                  {selectedMapCollections.length !== 1 && 's'}, {selectedTiers.size}{' '}\n                  content categor{selectedTiers.size !== 1 ? 'ies' : 'y'},{' '}\n                  {selectedAiModels.length} AI model{selectedAiModels.length !== 1 && 's'} selected\n                </p>\n              </div>\n\n              <div className=\"flex space-x-4\">\n                <StyledButton\n                  onClick={() => router.visit('/home')}\n                  disabled={isProcessing}\n                  variant=\"outline\"\n                >\n                  Cancel & Go to Home\n                </StyledButton>\n\n                {currentStep < 4 ? (\n                  <StyledButton\n                    onClick={handleNext}\n                    disabled={!canProceedToNextStep() || isProcessing}\n                    variant=\"primary\"\n                    icon=\"IconChevronRight\"\n                  >\n                    Next\n                  </StyledButton>\n                ) : (\n                  <StyledButton\n                    onClick={handleFinish}\n                    disabled={isProcessing || !isOnline || !anySelectionMade}\n                    loading={isProcessing}\n                    variant=\"success\"\n                    icon=\"IconCheck\"\n                  >\n                    Complete Setup\n                  </StyledButton>\n                )}\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </AppLayout>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/pages/errors/not_found.tsx",
    "content": "export default function NotFound() {\n  return (\n    <>\n      <div className=\"container\">\n        <div className=\"title\">Page not found</div>\n\n        <span>This page does not exist.</span>\n      </div>\n    </>\n  )\n}"
  },
  {
    "path": "admin/inertia/pages/errors/server_error.tsx",
    "content": "export default function ServerError(props: { error: any }) {\n  return (\n    <>\n      <div className=\"container\">\n        <div className=\"title\">Server Error</div>\n\n        <span>{props.error.message}</span>\n      </div>\n    </>\n  )\n}"
  },
  {
    "path": "admin/inertia/pages/home.tsx",
    "content": "import {\n  IconBolt,\n  IconHelp,\n  IconMapRoute,\n  IconPlus,\n  IconSettings,\n  IconWifiOff,\n} from '@tabler/icons-react'\nimport { Head, usePage } from '@inertiajs/react'\nimport AppLayout from '~/layouts/AppLayout'\nimport { getServiceLink } from '~/lib/navigation'\nimport { ServiceSlim } from '../../types/services'\nimport DynamicIcon, { DynamicIconName } from '~/components/DynamicIcon'\nimport { useUpdateAvailable } from '~/hooks/useUpdateAvailable'\nimport { useSystemSetting } from '~/hooks/useSystemSetting'\nimport Alert from '~/components/Alert'\nimport { SERVICE_NAMES } from '../../constants/service_names'\n\n// Maps is a Core Capability (display_order: 4)\nconst MAPS_ITEM = {\n  label: 'Maps',\n  to: '/maps',\n  target: '',\n  description: 'View offline maps',\n  icon: <IconMapRoute size={48} />,\n  installed: true,\n  displayOrder: 4,\n  poweredBy: null,\n}\n\n// System items shown after all apps\nconst SYSTEM_ITEMS = [\n  {\n    label: 'Easy Setup',\n    to: '/easy-setup',\n    target: '',\n    description:\n      'Not sure where to start? Use the setup wizard to quickly configure your N.O.M.A.D.!',\n    icon: <IconBolt size={48} />,\n    installed: true,\n    displayOrder: 50,\n    poweredBy: null,\n  },\n  {\n    label: 'Install Apps',\n    to: '/settings/apps',\n    target: '',\n    description: 'Not seeing your favorite app? Install it here!',\n    icon: <IconPlus size={48} />,\n    installed: true,\n    displayOrder: 51,\n    poweredBy: null,\n  },\n  {\n    label: 'Docs',\n    to: '/docs/home',\n    target: '',\n    description: 'Read Project N.O.M.A.D. manuals and guides',\n    icon: <IconHelp size={48} />,\n    installed: true,\n    displayOrder: 52,\n    poweredBy: null,\n  },\n  {\n    label: 'Settings',\n    to: '/settings/system',\n    target: '',\n    description: 'Configure your N.O.M.A.D. settings',\n    icon: <IconSettings size={48} />,\n    installed: true,\n    displayOrder: 53,\n    poweredBy: null,\n  },\n]\n\ninterface DashboardItem {\n  label: string\n  to: string\n  target: string\n  description: string\n  icon: React.ReactNode\n  installed: boolean\n  displayOrder: number\n  poweredBy: string | null\n}\n\nexport default function Home(props: {\n  system: {\n    services: ServiceSlim[]\n  }\n}) {\n  const items: DashboardItem[] = []\n  const updateInfo = useUpdateAvailable();\n  const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props\n\n  // Check if user has visited Easy Setup\n  const { data: easySetupVisited } = useSystemSetting({\n    key: 'ui.hasVisitedEasySetup'\n  })\n  const shouldHighlightEasySetup = easySetupVisited?.value ? String(easySetupVisited.value) !== 'true' : false\n\n  // Add installed services (non-dependency services only)\n  props.system.services\n    .filter((service) => service.installed && service.ui_location)\n    .forEach((service) => {\n      items.push({\n        // Inject custom AI Assistant name if this is the chat service\n        label: service.service_name === SERVICE_NAMES.OLLAMA && aiAssistantName ? aiAssistantName : (service.friendly_name || service.service_name),\n        to: service.ui_location ? getServiceLink(service.ui_location) : '#',\n        target: '_blank',\n        description:\n          service.description ||\n          `Access the ${service.friendly_name || service.service_name} application`,\n        icon: service.icon ? (\n          <DynamicIcon icon={service.icon as DynamicIconName} className=\"!size-12\" />\n        ) : (\n          <IconWifiOff size={48} />\n        ),\n        installed: service.installed,\n        displayOrder: service.display_order ?? 100,\n        poweredBy: service.powered_by ?? null,\n      })\n    })\n\n  // Add Maps as a Core Capability\n  items.push(MAPS_ITEM)\n\n  // Add system items\n  items.push(...SYSTEM_ITEMS)\n\n  // Sort all items by display order\n  items.sort((a, b) => a.displayOrder - b.displayOrder)\n\n  return (\n    <AppLayout>\n      <Head title=\"Command Center\" />\n      {\n        updateInfo?.updateAvailable && (\n          <div className='flex justify-center items-center p-4 w-full'>\n            <Alert\n              title=\"An update is available for Project N.O.M.A.D.!\"\n              type=\"info-inverted\"\n              variant=\"solid\"\n              className=\"w-full\"\n              buttonProps={{\n                variant: 'primary',\n                children: 'Go to Settings',\n                icon: 'IconSettings',\n                onClick: () => {\n                  window.location.href = '/settings/update'\n                },\n              }}\n            />\n          </div>\n        )\n      }\n      <div className=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 p-4\">\n        {items.map((item) => {\n          const isEasySetup = item.label === 'Easy Setup'\n          const shouldHighlight = isEasySetup && shouldHighlightEasySetup\n\n          return (\n            <a key={item.label} href={item.to} target={item.target}>\n              <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\">\n                {shouldHighlight && (\n                  <span className=\"absolute top-2 right-2 flex items-center justify-center\">\n                    <span\n                      className=\"animate-ping absolute inline-flex w-16 h-6 rounded-full bg-desert-orange-light opacity-75\"\n                      style={{ animationDuration: '1.5s' }}\n                    ></span>\n                    <span className=\"relative inline-flex items-center rounded-full px-2.5 py-1 bg-desert-orange-light text-xs font-semibold text-white shadow-sm\">\n                      Start here!\n                    </span>\n                  </span>\n                )}\n                <div className=\"flex items-center justify-center mb-2\">{item.icon}</div>\n                <h3 className=\"font-bold text-2xl\">{item.label}</h3>\n                {item.poweredBy && <p className=\"text-sm opacity-80\">Powered by {item.poweredBy}</p>}\n                <p className=\"xl:text-lg mt-2\">{item.description}</p>\n              </div>\n            </a>\n          )\n        })}\n      </div>\n    </AppLayout>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/pages/maps.tsx",
    "content": "import MapsLayout from '~/layouts/MapsLayout'\nimport { Head, Link } from '@inertiajs/react'\nimport MapComponent from '~/components/maps/MapComponent'\nimport StyledButton from '~/components/StyledButton'\nimport { IconArrowLeft } from '@tabler/icons-react'\nimport { FileEntry } from '../../types/files'\nimport Alert from '~/components/Alert'\n\nexport default function Maps(props: {\n  maps: { baseAssetsExist: boolean; regionFiles: FileEntry[] }\n}) {\n  const alertMessage = !props.maps.baseAssetsExist\n    ? 'The base map assets have not been installed. Please download them first to enable map functionality.'\n    : props.maps.regionFiles.length === 0\n      ? 'No map regions have been downloaded yet. Please download some regions to enable map functionality.'\n      : null\n\n  return (\n    <MapsLayout>\n      <Head title=\"Maps\" />\n      <div className=\"relative w-full h-screen overflow-hidden\">\n        {/* Nav and alerts are overlayed */}\n        <div className=\"absolute top-0 left-0 right-0 z-50 flex justify-between p-4 bg-surface-secondary backdrop-blur-sm shadow-sm\">\n          <Link href=\"/home\" className=\"flex items-center\">\n            <IconArrowLeft className=\"mr-2\" size={24} />\n            <p className=\"text-lg text-text-secondary\">Back to Home</p>\n          </Link>\n          <Link href=\"/settings/maps\" className='mr-4'>\n            <StyledButton variant=\"primary\" icon=\"IconSettings\">\n              Manage Map Regions\n            </StyledButton>\n          </Link>\n        </div>\n        {alertMessage && (\n          <div className=\"absolute top-20 left-4 right-4 z-50\">\n            <Alert\n              title={alertMessage}\n              type=\"warning\"\n              variant=\"solid\"\n              className=\"w-full\"\n              buttonProps={{\n                variant: 'secondary',\n                children: 'Go to Map Settings',\n                icon: 'IconSettings',\n                onClick: () => {\n                  window.location.href = '/settings/maps'\n                },\n              }}\n            />\n          </div>\n        )}\n        <div className=\"absolute inset-0\">\n          <MapComponent />\n        </div>\n      </div>\n    </MapsLayout>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/pages/settings/apps.tsx",
    "content": "import { Head } from '@inertiajs/react'\nimport StyledTable from '~/components/StyledTable'\nimport SettingsLayout from '~/layouts/SettingsLayout'\nimport { ServiceSlim } from '../../../types/services'\nimport { getServiceLink } from '~/lib/navigation'\nimport StyledButton from '~/components/StyledButton'\nimport { useModals } from '~/context/ModalContext'\nimport StyledModal from '~/components/StyledModal'\nimport api from '~/lib/api'\nimport { useEffect, useState } from 'react'\nimport InstallActivityFeed from '~/components/InstallActivityFeed'\nimport LoadingSpinner from '~/components/LoadingSpinner'\nimport useErrorNotification from '~/hooks/useErrorNotification'\nimport useInternetStatus from '~/hooks/useInternetStatus'\nimport useServiceInstallationActivity from '~/hooks/useServiceInstallationActivity'\nimport { useTransmit } from 'react-adonis-transmit'\nimport { BROADCAST_CHANNELS } from '../../../constants/broadcast'\nimport { IconArrowUp, IconCheck, IconDownload } from '@tabler/icons-react'\nimport UpdateServiceModal from '~/components/UpdateServiceModal'\n\nfunction extractTag(containerImage: string): string {\n  if (!containerImage) return ''\n  const parts = containerImage.split(':')\n  return parts.length > 1 ? parts[parts.length - 1] : 'latest'\n}\n\nexport default function SettingsPage(props: { system: { services: ServiceSlim[] } }) {\n  const { openModal, closeAllModals } = useModals()\n  const { showError } = useErrorNotification()\n  const { isOnline } = useInternetStatus()\n  const { subscribe } = useTransmit()\n  const installActivity = useServiceInstallationActivity()\n\n  const [isInstalling, setIsInstalling] = useState(false)\n  const [loading, setLoading] = useState(false)\n  const [checkingUpdates, setCheckingUpdates] = useState(false)\n\n  useEffect(() => {\n    if (installActivity.length === 0) return\n    if (\n      installActivity.some(\n        (activity) => activity.type === 'completed' || activity.type === 'update-complete'\n      )\n    ) {\n      setTimeout(() => {\n        window.location.reload()\n      }, 3000)\n    }\n  }, [installActivity])\n\n  // Listen for service update check completion\n  useEffect(() => {\n    const unsubscribe = subscribe(BROADCAST_CHANNELS.SERVICE_UPDATES, () => {\n      setCheckingUpdates(false)\n      window.location.reload()\n    })\n    return () => { unsubscribe() }\n  }, [])\n\n  async function handleCheckUpdates() {\n    try {\n      if (!isOnline) {\n        showError('You must have an internet connection to check for updates.')\n        return\n      }\n      setCheckingUpdates(true)\n      const response = await api.checkServiceUpdates()\n      if (!response?.success) {\n        throw new Error('Failed to dispatch update check')\n      }\n    } catch (error) {\n      console.error('Error checking for updates:', error)\n      showError(`Failed to check for updates: ${error.message || 'Unknown error'}`)\n      setCheckingUpdates(false)\n    }\n  }\n\n  const handleInstallService = (service: ServiceSlim) => {\n    openModal(\n      <StyledModal\n        title=\"Install Service?\"\n        onConfirm={() => {\n          installService(service.service_name)\n          closeAllModals()\n        }}\n        onCancel={closeAllModals}\n        open={true}\n        confirmText=\"Install\"\n        cancelText=\"Cancel\"\n        confirmVariant=\"primary\"\n        icon={<IconDownload className=\"h-12 w-12 text-desert-green\" />}\n      >\n        <p className=\"text-text-primary\">\n          Are you sure you want to install {service.friendly_name || service.service_name}? This\n          will start the service and make it available in your Project N.O.M.A.D. instance. It may\n          take some time to complete.\n        </p>\n      </StyledModal>,\n      'install-service-modal'\n    )\n  }\n\n  async function installService(serviceName: string) {\n    try {\n      if (!isOnline) {\n        showError('You must have an internet connection to install services.')\n        return\n      }\n\n      setIsInstalling(true)\n      const response = await api.installService(serviceName)\n      if (!response) {\n        throw new Error('An internal error occurred while trying to install the service.')\n      }\n      if (!response.success) {\n        throw new Error(response.message)\n      }\n    } catch (error) {\n      console.error('Error installing service:', error)\n      showError(`Failed to install service: ${error.message || 'Unknown error'}`)\n    } finally {\n      setIsInstalling(false)\n    }\n  }\n\n  async function handleAffectAction(record: ServiceSlim, action: 'start' | 'stop' | 'restart') {\n    try {\n      setLoading(true)\n      const response = await api.affectService(record.service_name, action)\n      if (!response) {\n        throw new Error('An internal error occurred while trying to affect the service.')\n      }\n      if (!response.success) {\n        throw new Error(response.message)\n      }\n\n      closeAllModals()\n\n      setTimeout(() => {\n        setLoading(false)\n        window.location.reload()\n      }, 3000)\n    } catch (error) {\n      console.error(`Error affecting service ${record.service_name}:`, error)\n      showError(`Failed to ${action} service: ${error.message || 'Unknown error'}`)\n    }\n  }\n\n  async function handleForceReinstall(record: ServiceSlim) {\n    try {\n      setLoading(true)\n      const response = await api.forceReinstallService(record.service_name)\n      if (!response) {\n        throw new Error('An internal error occurred while trying to force reinstall the service.')\n      }\n      if (!response.success) {\n        throw new Error(response.message)\n      }\n\n      closeAllModals()\n\n      setTimeout(() => {\n        setLoading(false)\n        window.location.reload()\n      }, 3000)\n    } catch (error) {\n      console.error(`Error force reinstalling service ${record.service_name}:`, error)\n      showError(`Failed to force reinstall service: ${error.message || 'Unknown error'}`)\n    }\n  }\n\n  function handleUpdateService(record: ServiceSlim) {\n    const currentTag = extractTag(record.container_image)\n    const latestVersion = record.available_update_version!\n\n    openModal(\n      <UpdateServiceModal\n        record={record}\n        currentTag={currentTag}\n        latestVersion={latestVersion}\n        onCancel={closeAllModals}\n        onUpdate={async (targetVersion: string) => {\n          closeAllModals()\n          try {\n            setLoading(true)\n            const response = await api.updateService(record.service_name, targetVersion)\n            if (!response?.success) {\n              throw new Error(response?.message || 'Update failed')\n            }\n          } catch (error) {\n            console.error(`Error updating service ${record.service_name}:`, error)\n            showError(`Failed to update service: ${error.message || 'Unknown error'}`)\n            setLoading(false)\n          }\n        }}\n        showError={showError}\n      />,\n      `${record.service_name}-update-modal`\n    )\n  }\n\n  const AppActions = ({ record }: { record: ServiceSlim }) => {\n    const ForceReinstallButton = () => (\n      <StyledButton\n        icon=\"IconDownload\"\n        variant=\"action\"\n        onClick={() => {\n          openModal(\n            <StyledModal\n              title={'Force Reinstall?'}\n              onConfirm={() => handleForceReinstall(record)}\n              onCancel={closeAllModals}\n              open={true}\n              confirmText={'Force Reinstall'}\n              cancelText=\"Cancel\"\n            >\n              <p className=\"text-text-primary\">\n                Are you sure you want to force reinstall {record.service_name}? This will{' '}\n                <strong>WIPE ALL DATA</strong> for this service and cannot be undone. You should\n                only do this if the service is malfunctioning and other troubleshooting steps have\n                failed.\n              </p>\n            </StyledModal>,\n            `${record.service_name}-force-reinstall-modal`\n          )\n        }}\n        disabled={isInstalling}\n      >\n        Force Reinstall\n      </StyledButton>\n    )\n\n    if (!record) return null\n    if (!record.installed) {\n      return (\n        <div className=\"flex flex-wrap gap-2\">\n          <StyledButton\n            icon={'IconDownload'}\n            variant=\"primary\"\n            onClick={() => handleInstallService(record)}\n            disabled={isInstalling || !isOnline}\n            loading={isInstalling}\n          >\n            Install\n          </StyledButton>\n          <ForceReinstallButton />\n        </div>\n      )\n    }\n\n    return (\n      <div className=\"flex flex-wrap gap-2\">\n        <StyledButton\n          icon={'IconExternalLink'}\n          onClick={() => {\n            window.open(getServiceLink(record.ui_location || 'unknown'), '_blank')\n          }}\n        >\n          Open\n        </StyledButton>\n        {record.available_update_version && (\n          <StyledButton\n            icon=\"IconArrowUp\"\n            variant=\"primary\"\n            onClick={() => handleUpdateService(record)}\n            disabled={isInstalling || !isOnline}\n          >\n            Update\n          </StyledButton>\n        )}\n        {record.status && record.status !== 'unknown' && (\n          <>\n            <StyledButton\n              icon={record.status === 'running' ? 'IconPlayerStop' : 'IconPlayerPlay'}\n              variant={record.status === 'running' ? 'action' : undefined}\n              onClick={() => {\n                openModal(\n                  <StyledModal\n                    title={`${record.status === 'running' ? 'Stop' : 'Start'} Service?`}\n                    onConfirm={() =>\n                      handleAffectAction(record, record.status === 'running' ? 'stop' : 'start')\n                    }\n                    onCancel={closeAllModals}\n                    open={true}\n                    confirmText={record.status === 'running' ? 'Stop' : 'Start'}\n                    cancelText=\"Cancel\"\n                  >\n                    <p className=\"text-text-primary\">\n                      Are you sure you want to {record.status === 'running' ? 'stop' : 'start'}{' '}\n                      {record.service_name}?\n                    </p>\n                  </StyledModal>,\n                  `${record.service_name}-affect-modal`\n                )\n              }}\n              disabled={isInstalling}\n            >\n              {record.status === 'running' ? 'Stop' : 'Start'}\n            </StyledButton>\n            {record.status === 'running' && (\n              <StyledButton\n                icon=\"IconRefresh\"\n                variant=\"action\"\n                onClick={() => {\n                  openModal(\n                    <StyledModal\n                      title={'Restart Service?'}\n                      onConfirm={() => handleAffectAction(record, 'restart')}\n                      onCancel={closeAllModals}\n                      open={true}\n                      confirmText={'Restart'}\n                      cancelText=\"Cancel\"\n                    >\n                      <p className=\"text-text-primary\">\n                        Are you sure you want to restart {record.service_name}?\n                      </p>\n                    </StyledModal>,\n                    `${record.service_name}-affect-modal`\n                  )\n                }}\n                disabled={isInstalling}\n              >\n                Restart\n              </StyledButton>\n            )}\n            <ForceReinstallButton />\n          </>\n        )}\n      </div>\n    )\n  }\n\n  return (\n    <SettingsLayout>\n      <Head title=\"App Settings\" />\n      <div className=\"xl:pl-72 w-full\">\n        <main className=\"px-12 py-6\">\n          <div className=\"flex items-center justify-between mb-4\">\n            <div>\n              <h1 className=\"text-4xl font-semibold\">Apps</h1>\n              <p className=\"text-text-muted mt-1\">\n                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.\n              </p>\n            </div>\n            <StyledButton\n              icon=\"IconRefreshAlert\"\n              onClick={handleCheckUpdates}\n              disabled={checkingUpdates || !isOnline}\n              loading={checkingUpdates}\n            >\n              Check for Updates\n            </StyledButton>\n          </div>\n          {loading && <LoadingSpinner fullscreen />}\n          {!loading && (\n            <StyledTable<ServiceSlim & { actions?: any }>\n              className=\"font-semibold !overflow-x-auto\"\n              rowLines={true}\n              columns={[\n                {\n                  accessor: 'friendly_name',\n                  title: 'Name',\n                  render(record) {\n                    return (\n                      <div className=\"flex flex-col\">\n                        <p>{record.friendly_name || record.service_name}</p>\n                        <p className=\"text-sm text-text-muted\">{record.description}</p>\n                      </div>\n                    )\n                  },\n                },\n                {\n                  accessor: 'ui_location',\n                  title: 'Location',\n                  render: (record) => (\n                    <a\n                      href={getServiceLink(record.ui_location || 'unknown')}\n                      target=\"_blank\"\n                      rel=\"noopener noreferrer\"\n                      className=\"text-desert-green hover:underline font-semibold\"\n                    >\n                      {record.ui_location}\n                    </a>\n                  ),\n                },\n                {\n                  accessor: 'installed',\n                  title: 'Installed',\n                  render: (record) =>\n                    record.installed ? <IconCheck className=\"h-6 w-6 text-desert-green\" /> : '',\n                },\n                {\n                  accessor: 'container_image',\n                  title: 'Version',\n                  render: (record) => {\n                    if (!record.installed) return null\n                    const currentTag = extractTag(record.container_image)\n                    if (record.available_update_version) {\n                      return (\n                        <div className=\"flex items-center gap-1.5\">\n                          <span className=\"text-text-muted\">{currentTag}</span>\n                          <IconArrowUp className=\"h-4 w-4 text-desert-green\" />\n                          <span className=\"text-desert-green font-semibold\">\n                            {record.available_update_version}\n                          </span>\n                        </div>\n                      )\n                    }\n                    return <span className=\"text-text-secondary\">{currentTag}</span>\n                  },\n                },\n                {\n                  accessor: 'actions',\n                  title: 'Actions',\n                  className: '!whitespace-normal',\n                  render: (record) => <AppActions record={record} />,\n                },\n              ]}\n              data={props.system.services}\n            />\n          )}\n          {installActivity.length > 0 && (\n            <InstallActivityFeed activity={installActivity} className=\"mt-8\" withHeader />\n          )}\n        </main>\n      </div>\n    </SettingsLayout>\n  )\n}\n\n"
  },
  {
    "path": "admin/inertia/pages/settings/benchmark.tsx",
    "content": "import { Head, Link, usePage } from '@inertiajs/react'\nimport { useState, useEffect, useRef } from 'react'\nimport SettingsLayout from '~/layouts/SettingsLayout'\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'\nimport CircularGauge from '~/components/systeminfo/CircularGauge'\nimport InfoCard from '~/components/systeminfo/InfoCard'\nimport Alert from '~/components/Alert'\nimport StyledButton from '~/components/StyledButton'\nimport InfoTooltip from '~/components/InfoTooltip'\nimport BuilderTagSelector from '~/components/BuilderTagSelector'\nimport {\n  IconRobot,\n  IconChartBar,\n  IconCpu,\n  IconDatabase,\n  IconServer,\n  IconChevronDown,\n  IconClock,\n} from '@tabler/icons-react'\nimport { useTransmit } from 'react-adonis-transmit'\nimport { BenchmarkProgress, BenchmarkStatus } from '../../../types/benchmark'\nimport BenchmarkResult from '#models/benchmark_result'\nimport api from '~/lib/api'\nimport useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'\nimport { SERVICE_NAMES } from '../../../constants/service_names'\nimport { BROADCAST_CHANNELS } from '../../../constants/broadcast'\n\ntype BenchmarkProgressWithID = BenchmarkProgress & { benchmark_id: string }\n\nexport default function BenchmarkPage(props: {\n  benchmark: {\n    latestResult: BenchmarkResult | null\n    status: BenchmarkStatus\n    currentBenchmarkId: string | null\n  }\n}) {\n  const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props\n  const { subscribe } = useTransmit()\n  const queryClient = useQueryClient()\n  const aiInstalled = useServiceInstalledStatus(SERVICE_NAMES.OLLAMA)\n  const [progress, setProgress] = useState<BenchmarkProgressWithID | null>(null)\n  const [isRunning, setIsRunning] = useState(props.benchmark.status !== 'idle')\n  const refetchLatestRef = useRef<(() => void) | null>(null)\n  const [showDetails, setShowDetails] = useState(false)\n  const [showHistory, setShowHistory] = useState(false)\n  const [showAIRequiredAlert, setShowAIRequiredAlert] = useState(false)\n  const [shareAnonymously, setShareAnonymously] = useState(false)\n  const [currentBuilderTag, setCurrentBuilderTag] = useState<string | null>(\n    props.benchmark.latestResult?.builder_tag || null\n  )\n\n  // Fetch latest result\n  const { data: latestResult, refetch: refetchLatest } = useQuery({\n    queryKey: ['benchmark', 'latest'],\n    queryFn: async () => {\n      const res = await api.getLatestBenchmarkResult()\n      if (res && res.result) {\n        return res.result\n      }\n      return null\n    },\n    initialData: props.benchmark.latestResult,\n  })\n  refetchLatestRef.current = refetchLatest\n\n  // Fetch all benchmark results for history\n  const { data: benchmarkHistory } = useQuery({\n    queryKey: ['benchmark', 'history'],\n    queryFn: async () => {\n      const res = await api.getBenchmarkResults()\n      if (res && res.results && Array.isArray(res.results)) {\n        return res.results\n      }\n      return []\n    },\n  })\n\n  // Run benchmark mutation (uses sync mode by default for simpler local dev)\n  const runBenchmark = useMutation({\n    mutationFn: async (type: 'full' | 'system' | 'ai') => {\n      setIsRunning(true)\n      setProgress({\n        status: 'starting',\n        progress: 5,\n        message: 'Starting benchmark... This takes 2-5 minutes.',\n        current_stage: 'Starting',\n        benchmark_id: '',\n        timestamp: new Date().toISOString(),\n      })\n\n      // Use sync mode - runs inline without needing Redis/queue worker\n      return await api.runBenchmark(type, true)\n    },\n    onSuccess: (data) => {\n      if (data?.success) {\n        setProgress({\n          status: 'completed',\n          progress: 100,\n          message: 'Benchmark completed!',\n          current_stage: 'Complete',\n          benchmark_id: data.benchmark_id,\n          timestamp: new Date().toISOString(),\n        })\n        refetchLatest()\n      } else {\n        setProgress({\n          status: 'error',\n          progress: 0,\n          message: 'Benchmark failed',\n          current_stage: 'Error',\n          benchmark_id: '',\n          timestamp: new Date().toISOString(),\n        })\n      }\n      setIsRunning(false)\n    },\n    onError: (error) => {\n      setProgress({\n        status: 'error',\n        progress: 0,\n        message: error.message || 'Benchmark failed',\n        current_stage: 'Error',\n        benchmark_id: '',\n        timestamp: new Date().toISOString(),\n      })\n      setIsRunning(false)\n    },\n  })\n\n  // Update builder tag mutation\n  const updateBuilderTag = useMutation({\n    mutationFn: async ({\n      benchmarkId,\n      builderTag,\n    }: {\n      benchmarkId: string\n      builderTag: string\n      invalidate?: boolean\n    }) => {\n      const res = await api.updateBuilderTag(benchmarkId, builderTag)\n      if (!res || !res.success) {\n        throw new Error(res?.error || 'Failed to update builder tag')\n      }\n      return res\n    },\n    onSuccess: (_, variables) => {\n      if (variables.invalidate) {\n        refetchLatest()\n        queryClient.invalidateQueries({ queryKey: ['benchmark', 'history'] })\n      }\n    },\n  })\n\n  // Submit to repository mutation\n  const [submitError, setSubmitError] = useState<string | null>(null)\n  const submitResult = useMutation({\n    mutationFn: async ({ benchmarkId, anonymous }: { benchmarkId: string; anonymous: boolean }) => {\n      setSubmitError(null)\n\n      // First, save the current builder tag to the benchmark (don't refetch yet)\n      if (currentBuilderTag && !anonymous) {\n        await updateBuilderTag.mutateAsync({\n          benchmarkId,\n          builderTag: currentBuilderTag,\n          invalidate: false,\n        })\n      }\n\n      const res = await api.submitBenchmark(benchmarkId, anonymous)\n      if (!res || !res.success) {\n        throw new Error(res?.error || 'Failed to submit benchmark')\n      }\n      return res\n    },\n    onSuccess: () => {\n      refetchLatest()\n      queryClient.invalidateQueries({ queryKey: ['benchmark', 'history'] })\n    },\n    onError: (error: any) => {\n      // Check if this is a 409 Conflict error (already submitted)\n      if (error.status === 409) {\n        setSubmitError('A benchmark for this system with the same or higher score has already been submitted.')\n      } else {\n        setSubmitError(error.message)\n      }\n    },\n  })\n\n  // Check if the latest result is a full benchmark with AI data (eligible for sharing)\n  const canShareBenchmark =\n    latestResult &&\n    latestResult.benchmark_type === 'full' &&\n    latestResult.ai_tokens_per_second !== null &&\n    latestResult.ai_tokens_per_second > 0 &&\n    !latestResult.submitted_to_repository\n\n  // Handle Full Benchmark click with pre-flight check\n  const handleFullBenchmarkClick = () => {\n    if (!aiInstalled) {\n      setShowAIRequiredAlert(true)\n      return\n    }\n    setShowAIRequiredAlert(false)\n    runBenchmark.mutate('full')\n  }\n\n  // Simulate progress during sync benchmark (since we don't get SSE updates)\n  useEffect(() => {\n    if (!isRunning || progress?.status === 'completed' || progress?.status === 'error') return\n\n    const stages: {\n      status: BenchmarkStatus\n      progress: number\n      message: string\n      label: string\n      duration: number\n    }[] = [\n      {\n        status: 'detecting_hardware',\n        progress: 10,\n        message: 'Detecting system hardware...',\n        label: 'Detecting Hardware',\n        duration: 2000,\n      },\n      {\n        status: 'running_cpu',\n        progress: 25,\n        message: 'Running CPU benchmark (30s)...',\n        label: 'CPU Benchmark',\n        duration: 32000,\n      },\n      {\n        status: 'running_memory',\n        progress: 40,\n        message: 'Running memory benchmark...',\n        label: 'Memory Benchmark',\n        duration: 8000,\n      },\n      {\n        status: 'running_disk_read',\n        progress: 55,\n        message: 'Running disk read benchmark (30s)...',\n        label: 'Disk Read Test',\n        duration: 35000,\n      },\n      {\n        status: 'running_disk_write',\n        progress: 70,\n        message: 'Running disk write benchmark (30s)...',\n        label: 'Disk Write Test',\n        duration: 35000,\n      },\n      {\n        status: 'downloading_ai_model',\n        progress: 80,\n        message: 'Downloading AI benchmark model (first run only)...',\n        label: 'Downloading AI Model',\n        duration: 5000,\n      },\n      {\n        status: 'running_ai',\n        progress: 85,\n        message: 'Running AI inference benchmark...',\n        label: 'AI Inference Test',\n        duration: 15000,\n      },\n      {\n        status: 'calculating_score',\n        progress: 95,\n        message: 'Calculating NOMAD score...',\n        label: 'Calculating Score',\n        duration: 2000,\n      },\n    ]\n\n    let currentStage = 0\n    const advanceStage = () => {\n      if (currentStage < stages.length && isRunning) {\n        const stage = stages[currentStage]\n        setProgress({\n          status: stage.status,\n          progress: stage.progress,\n          message: stage.message,\n          current_stage: stage.label,\n          benchmark_id: '',\n          timestamp: new Date().toISOString(),\n        })\n        currentStage++\n      }\n    }\n\n    // Start the first stage after a short delay\n    const timers: NodeJS.Timeout[] = []\n    let elapsed = 1000\n    stages.forEach((stage) => {\n      timers.push(setTimeout(() => advanceStage(), elapsed))\n      elapsed += stage.duration\n    })\n\n    return () => {\n      timers.forEach((t) => clearTimeout(t))\n    }\n  }, [isRunning])\n\n  // Listen for benchmark progress via SSE (backup for async mode)\n  useEffect(() => {\n    const unsubscribe = subscribe(BROADCAST_CHANNELS.BENCHMARK_PROGRESS, (data: BenchmarkProgressWithID) => {\n      setProgress(data)\n      if (data.status === 'completed' || data.status === 'error') {\n        setIsRunning(false)\n        refetchLatestRef.current?.()\n      }\n    })\n\n    return () => {\n      unsubscribe()\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [subscribe])\n\n  const formatBytes = (bytes: number) => {\n    const gb = bytes / (1024 * 1024 * 1024)\n    return `${gb.toFixed(1)} GB`\n  }\n\n  const getScoreColor = (score: number) => {\n    if (score >= 70) return 'text-green-600'\n    if (score >= 40) return 'text-yellow-600'\n    return 'text-red-600'\n  }\n\n  const getProgressPercent = () => {\n    if (!progress) return 0\n    const stages: Record<BenchmarkStatus, number> = {\n      idle: 0,\n      starting: 5,\n      detecting_hardware: 10,\n      running_cpu: 25,\n      running_memory: 40,\n      running_disk_read: 55,\n      running_disk_write: 70,\n      downloading_ai_model: 80,\n      running_ai: 85,\n      calculating_score: 95,\n      completed: 100,\n      error: 0,\n    }\n    return stages[progress.status] || 0\n  }\n\n  // Calculate AI score from tokens per second (normalized to 0-100)\n  // Reference: 30 tok/s = 50 score, 60 tok/s = 100 score\n  const getAIScore = (tokensPerSecond: number | null): number => {\n    if (!tokensPerSecond) return 0\n    const score = (tokensPerSecond / 60) * 100\n    return Math.min(100, Math.max(0, score))\n  }\n\n  return (\n    <SettingsLayout>\n      <Head title=\"System Benchmark\" />\n      <div className=\"xl:pl-72 w-full\">\n        <main className=\"px-6 lg:px-12 py-6 lg:py-8\">\n          <div className=\"mb-8\">\n            <h1 className=\"text-4xl font-bold text-desert-green mb-2\">System Benchmark</h1>\n            <p className=\"text-desert-stone-dark\">\n              Measure your server's performance and compare with the NOMAD community\n            </p>\n          </div>\n\n          {/* Run Benchmark Section */}\n          <section className=\"mb-12\">\n            <h2 className=\"text-2xl font-bold text-desert-green mb-6 flex items-center gap-2\">\n              <div className=\"w-1 h-6 bg-desert-green\" />\n              Run Benchmark\n            </h2>\n\n            <div className=\"bg-desert-white rounded-lg p-8 border border-desert-stone-light shadow-sm\">\n              {isRunning ? (\n                <div className=\"space-y-4\">\n                  <div className=\"flex items-center gap-3\">\n                    <div className=\"animate-spin h-6 w-6 border-2 border-desert-green border-t-transparent rounded-full\" />\n                    <span className=\"text-lg font-medium\">\n                      {progress?.current_stage || 'Running benchmark...'}\n                    </span>\n                  </div>\n                  <div className=\"w-full bg-desert-stone-lighter rounded-full h-4 overflow-hidden\">\n                    <div\n                      className=\"bg-desert-green h-full transition-all duration-500\"\n                      style={{ width: `${getProgressPercent()}%` }}\n                    />\n                  </div>\n                  <p className=\"text-sm text-desert-stone-dark\">{progress?.message}</p>\n                </div>\n              ) : (\n                <div className=\"space-y-6\">\n                  {progress?.status === 'error' && (\n                    <Alert\n                      type=\"error\"\n                      title=\"Benchmark Failed\"\n                      message={progress.message}\n                      variant=\"bordered\"\n                      dismissible\n                      onDismiss={() => setProgress(null)}\n                    />\n                  )}\n                  {showAIRequiredAlert && (\n                    <Alert\n                      type=\"warning\"\n                      title={`${aiAssistantName} Required`}\n                      message={`Full benchmark requires ${aiAssistantName} to be installed. Install it to measure your complete NOMAD capability and share results with the community.`}\n                      variant=\"bordered\"\n                      dismissible\n                      onDismiss={() => setShowAIRequiredAlert(false)}\n                    >\n                      <Link\n                        href=\"/settings/apps\"\n                        className=\"text-sm text-desert-green hover:underline mt-2 inline-block font-medium\"\n                      >\n                        Go to Apps to install {aiAssistantName} →\n                      </Link>\n                    </Alert>\n                  )}\n                  <p className=\"text-desert-stone-dark\">\n                    Run a benchmark to measure your system's CPU, memory, disk, and AI inference\n                    performance. The benchmark takes approximately 2-5 minutes to complete.\n                  </p>\n                  <div className=\"flex flex-wrap gap-4\">\n                    <StyledButton\n                      onClick={handleFullBenchmarkClick}\n                      disabled={runBenchmark.isPending}\n                      icon=\"IconPlayerPlay\"\n                    >\n                      Run Full Benchmark\n                    </StyledButton>\n                    <StyledButton\n                      variant=\"secondary\"\n                      onClick={() => runBenchmark.mutate('system')}\n                      disabled={runBenchmark.isPending}\n                      icon=\"IconCpu\"\n                    >\n                      System Only\n                    </StyledButton>\n                    <StyledButton\n                      variant=\"secondary\"\n                      onClick={() => runBenchmark.mutate('ai')}\n                      disabled={runBenchmark.isPending || !aiInstalled}\n                      icon=\"IconWand\"\n                      title={\n                        !aiInstalled\n                          ? `${aiAssistantName} must be installed to run AI benchmark`\n                          : undefined\n                      }\n                    >\n                      AI Only\n                    </StyledButton>\n                  </div>\n                  {!aiInstalled && (\n                    <p className=\"text-sm text-desert-stone-dark\">\n                      <span className=\"text-amber-600\">Note:</span> {aiAssistantName} is not\n                      installed.\n                      <Link\n                        href=\"/settings/apps\"\n                        className=\"text-desert-green hover:underline ml-1\"\n                      >\n                        Install it\n                      </Link>{' '}\n                      to run full benchmarks and share results with the community.\n                    </p>\n                  )}\n                </div>\n              )}\n            </div>\n          </section>\n\n          {/* Results Section */}\n          {latestResult && (\n            <>\n              <section className=\"mb-12\">\n                <h2 className=\"text-2xl font-bold text-desert-green mb-6 flex items-center gap-2\">\n                  <div className=\"w-1 h-6 bg-desert-green\" />\n                  NOMAD Score\n                </h2>\n\n                <div className=\"bg-desert-white rounded-lg p-8 border border-desert-stone-light shadow-sm\">\n                  <div className=\"flex flex-col md:flex-row items-center gap-8\">\n                    <div className=\"shrink-0\">\n                      <CircularGauge\n                        value={latestResult.nomad_score}\n                        label=\"NOMAD Score\"\n                        size=\"lg\"\n                        variant=\"cpu\"\n                        subtext=\"out of 100\"\n                        icon={<IconChartBar className=\"w-8 h-8\" />}\n                      />\n                    </div>\n                    <div className=\"flex-1 space-y-4\">\n                      <div\n                        className={`text-5xl font-bold ${getScoreColor(latestResult.nomad_score)}`}\n                      >\n                        {latestResult.nomad_score.toFixed(1)}\n                      </div>\n                      <p className=\"text-desert-stone-dark\">\n                        Your NOMAD Score is a weighted composite of all benchmark results.\n                      </p>\n\n                      {/* Share with Community - Only for full benchmarks with AI data */}\n                      {canShareBenchmark && (\n                        <div className=\"space-y-4 mt-6 pt-6 border-t border-desert-stone-light\">\n                          <h3 className=\"font-semibold text-desert-green\">Share with Community</h3>\n                          <p className=\"text-sm text-desert-stone-dark\">\n                            Share your benchmark on the community leaderboard. Choose a Builder Tag\n                            to claim your spot, or share anonymously.\n                          </p>\n\n                          {/* Builder Tag Selector */}\n                          <div className=\"space-y-2\">\n                            <label className=\"block text-sm font-medium text-desert-stone-dark\">\n                              Your Builder Tag\n                            </label>\n                            <BuilderTagSelector\n                              value={currentBuilderTag}\n                              onChange={setCurrentBuilderTag}\n                              disabled={shareAnonymously || submitResult.isPending}\n                            />\n                          </div>\n\n                          {/* Anonymous checkbox */}\n                          <label className=\"flex items-center gap-2 cursor-pointer\">\n                            <input\n                              type=\"checkbox\"\n                              checked={shareAnonymously}\n                              onChange={(e) => setShareAnonymously(e.target.checked)}\n                              disabled={submitResult.isPending}\n                              className=\"w-4 h-4 rounded border-desert-stone-light text-desert-green focus:ring-desert-green\"\n                            />\n                            <span className=\"text-sm text-desert-stone-dark\">\n                              Share anonymously (no Builder Tag shown on leaderboard)\n                            </span>\n                          </label>\n\n                          <StyledButton\n                            onClick={() =>\n                              submitResult.mutate({\n                                benchmarkId: latestResult.benchmark_id,\n                                anonymous: shareAnonymously,\n                              })\n                            }\n                            disabled={submitResult.isPending}\n                            icon=\"IconCloudUpload\"\n                          >\n                            {submitResult.isPending ? 'Submitting...' : 'Share with Community'}\n                          </StyledButton>\n                          {submitError && (\n                            <Alert\n                              type=\"error\"\n                              title=\"Submission Failed\"\n                              message={submitError}\n                              variant=\"bordered\"\n                              dismissible\n                              onDismiss={() => setSubmitError(null)}\n                            />\n                          )}\n                        </div>\n                      )}\n\n                      {/* Show message for partial benchmarks */}\n                      {latestResult &&\n                        !latestResult.submitted_to_repository &&\n                        !canShareBenchmark && (\n                          <Alert\n                            type=\"info\"\n                            title=\"Partial Benchmark\"\n                            message={`This ${latestResult.benchmark_type} benchmark cannot be shared with the community. Run a Full Benchmark with ${aiAssistantName} installed to share your results.`}\n                            variant=\"bordered\"\n                          />\n                        )}\n\n                      {latestResult.submitted_to_repository && (\n                        <Alert\n                          type=\"success\"\n                          title=\"Shared with Community\"\n                          message=\"Your benchmark has been submitted to the community leaderboard. Thanks for contributing!\"\n                          variant=\"bordered\"\n                        >\n                          <a\n                            href=\"https://benchmark.projectnomad.us\"\n                            target=\"_blank\"\n                            rel=\"noopener noreferrer\"\n                            className=\"text-sm text-desert-green hover:underline mt-2 inline-block\"\n                          >\n                            View the leaderboard →\n                          </a>\n                        </Alert>\n                      )}\n                    </div>\n                  </div>\n                </div>\n              </section>\n\n              <section className=\"mb-12\">\n                <h2 className=\"text-2xl font-bold text-desert-green mb-6 flex items-center gap-2\">\n                  <div className=\"w-1 h-6 bg-desert-green\" />\n                  System Performance\n                </h2>\n\n                <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6\">\n                  <div className=\"bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm\">\n                    <CircularGauge\n                      value={latestResult.cpu_score * 100}\n                      label=\"CPU\"\n                      size=\"md\"\n                      variant=\"cpu\"\n                      icon={<IconCpu className=\"w-6 h-6\" />}\n                    />\n                  </div>\n                  <div className=\"bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm\">\n                    <CircularGauge\n                      value={latestResult.memory_score * 100}\n                      label=\"Memory\"\n                      size=\"md\"\n                      variant=\"memory\"\n                      icon={<IconDatabase className=\"w-6 h-6\" />}\n                    />\n                  </div>\n                  <div className=\"bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm\">\n                    <CircularGauge\n                      value={latestResult.disk_read_score * 100}\n                      label=\"Disk Read\"\n                      size=\"md\"\n                      variant=\"disk\"\n                      icon={<IconServer className=\"w-6 h-6\" />}\n                    />\n                  </div>\n                  <div className=\"bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm\">\n                    <CircularGauge\n                      value={latestResult.disk_write_score * 100}\n                      label=\"Disk Write\"\n                      size=\"md\"\n                      variant=\"disk\"\n                      icon={<IconServer className=\"w-6 h-6\" />}\n                    />\n                  </div>\n                </div>\n              </section>\n\n              {/* AI Performance Section */}\n              <section className=\"mb-12\">\n                <h2 className=\"text-2xl font-bold text-desert-green mb-6 flex items-center gap-2\">\n                  <div className=\"w-1 h-6 bg-desert-green\" />\n                  AI Performance\n                </h2>\n\n                {latestResult.ai_tokens_per_second ? (\n                  <div className=\"grid grid-cols-1 md:grid-cols-3 gap-6\">\n                    <div className=\"bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm\">\n                      <CircularGauge\n                        value={getAIScore(latestResult.ai_tokens_per_second)}\n                        label=\"AI Score\"\n                        size=\"md\"\n                        variant=\"cpu\"\n                        icon={<IconRobot className=\"w-6 h-6\" />}\n                      />\n                    </div>\n                    <div className=\"bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm flex items-center justify-center\">\n                      <div className=\"flex items-center gap-4\">\n                        <IconRobot className=\"w-10 h-10 text-desert-green\" />\n                        <div>\n                          <div className=\"text-3xl font-bold text-desert-green\">\n                            {latestResult.ai_tokens_per_second.toFixed(1)}\n                          </div>\n                          <div className=\"text-sm text-desert-stone-dark flex items-center gap-1\">\n                            Tokens per Second\n                            <InfoTooltip text=\"How fast the AI generates text. Higher is better. 30+ tokens/sec feels responsive, 60+ feels instant.\" />\n                          </div>\n                        </div>\n                      </div>\n                    </div>\n                    <div className=\"bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm flex items-center justify-center\">\n                      <div className=\"flex items-center gap-4\">\n                        <IconRobot className=\"w-10 h-10 text-desert-green\" />\n                        <div>\n                          <div className=\"text-3xl font-bold text-desert-green\">\n                            {latestResult.ai_time_to_first_token?.toFixed(0) || 'N/A'} ms\n                          </div>\n                          <div className=\"text-sm text-desert-stone-dark flex items-center gap-1\">\n                            Time to First Token\n                            <InfoTooltip text=\"How quickly the AI starts responding after you send a message. Lower is better. Under 500ms feels instant.\" />\n                          </div>\n                        </div>\n                      </div>\n                    </div>\n                  </div>\n                ) : (\n                  <div className=\"bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm\">\n                    <div className=\"text-center text-desert-stone-dark\">\n                      <IconRobot className=\"w-12 h-12 mx-auto mb-3 opacity-40\" />\n                      <p className=\"font-medium\">No AI Benchmark Data</p>\n                      <p className=\"text-sm mt-1\">\n                        Run a Full Benchmark or AI Only benchmark to measure AI inference\n                        performance.\n                      </p>\n                    </div>\n                  </div>\n                )}\n              </section>\n\n              <section className=\"mb-12\">\n                <h2 className=\"text-2xl font-bold text-desert-green mb-6 flex items-center gap-2\">\n                  <div className=\"w-1 h-6 bg-desert-green\" />\n                  Hardware Information\n                </h2>\n\n                <div className=\"grid grid-cols-1 lg:grid-cols-2 gap-6\">\n                  <InfoCard\n                    title=\"Processor\"\n                    icon={<IconCpu className=\"w-6 h-6\" />}\n                    variant=\"elevated\"\n                    data={[\n                      { label: 'Model', value: latestResult.cpu_model },\n                      { label: 'Cores', value: latestResult.cpu_cores },\n                      { label: 'Threads', value: latestResult.cpu_threads },\n                    ]}\n                  />\n                  <InfoCard\n                    title=\"System\"\n                    icon={<IconServer className=\"w-6 h-6\" />}\n                    variant=\"elevated\"\n                    data={[\n                      { label: 'RAM', value: formatBytes(latestResult.ram_bytes) },\n                      { label: 'Disk Type', value: latestResult.disk_type.toUpperCase() },\n                      { label: 'GPU', value: latestResult.gpu_model || 'Not detected' },\n                    ]}\n                  />\n                </div>\n              </section>\n\n              <section>\n                <h2 className=\"text-2xl font-bold text-desert-green mb-6 flex items-center gap-2\">\n                  <div className=\"w-1 h-6 bg-desert-green\" />\n                  Benchmark Details\n                </h2>\n\n                <div className=\"bg-desert-white rounded-lg border border-desert-stone-light shadow-sm overflow-hidden\">\n                  {/* Summary row - always visible */}\n                  <button\n                    onClick={() => setShowDetails(!showDetails)}\n                    className=\"w-full p-6 flex items-center justify-between hover:bg-desert-stone-lighter/30 transition-colors\"\n                  >\n                    <div className=\"grid grid-cols-2 md:grid-cols-4 gap-4 text-sm text-left flex-1\">\n                      <div>\n                        <div className=\"text-desert-stone-dark\">Benchmark ID</div>\n                        <div className=\"font-mono text-xs\">\n                          {latestResult.benchmark_id.slice(0, 8)}...\n                        </div>\n                      </div>\n                      <div>\n                        <div className=\"text-desert-stone-dark\">Type</div>\n                        <div className=\"capitalize\">{latestResult.benchmark_type}</div>\n                      </div>\n                      <div>\n                        <div className=\"text-desert-stone-dark\">Date</div>\n                        <div>\n                          {new Date(\n                            latestResult.created_at as unknown as string\n                          ).toLocaleDateString()}\n                        </div>\n                      </div>\n                      <div>\n                        <div className=\"text-desert-stone-dark\">NOMAD Score</div>\n                        <div className=\"font-bold text-desert-green\">\n                          {latestResult.nomad_score.toFixed(1)}\n                        </div>\n                      </div>\n                    </div>\n                    <IconChevronDown\n                      className={`w-5 h-5 text-desert-stone-dark transition-transform ${showDetails ? 'rotate-180' : ''}`}\n                    />\n                  </button>\n\n                  {/* Expanded details */}\n                  {showDetails && (\n                    <div className=\"border-t border-desert-stone-light p-6 bg-desert-stone-lighter/20\">\n                      <div className=\"grid grid-cols-1 md:grid-cols-2 gap-6\">\n                        {/* Raw Scores */}\n                        <div>\n                          <h4 className=\"font-semibold text-desert-green mb-3\">Raw Scores</h4>\n                          <div className=\"space-y-2 text-sm\">\n                            <div className=\"flex justify-between\">\n                              <span className=\"text-desert-stone-dark\">CPU Score</span>\n                              <span className=\"font-mono\">\n                                {(latestResult.cpu_score * 100).toFixed(1)}%\n                              </span>\n                            </div>\n                            <div className=\"flex justify-between\">\n                              <span className=\"text-desert-stone-dark\">Memory Score</span>\n                              <span className=\"font-mono\">\n                                {(latestResult.memory_score * 100).toFixed(1)}%\n                              </span>\n                            </div>\n                            <div className=\"flex justify-between\">\n                              <span className=\"text-desert-stone-dark\">Disk Read Score</span>\n                              <span className=\"font-mono\">\n                                {(latestResult.disk_read_score * 100).toFixed(1)}%\n                              </span>\n                            </div>\n                            <div className=\"flex justify-between\">\n                              <span className=\"text-desert-stone-dark\">Disk Write Score</span>\n                              <span className=\"font-mono\">\n                                {(latestResult.disk_write_score * 100).toFixed(1)}%\n                              </span>\n                            </div>\n                            {latestResult.ai_tokens_per_second && (\n                              <>\n                                <div className=\"flex justify-between\">\n                                  <span className=\"text-desert-stone-dark\">AI Tokens/sec</span>\n                                  <span className=\"font-mono\">\n                                    {latestResult.ai_tokens_per_second.toFixed(1)}\n                                  </span>\n                                </div>\n                                <div className=\"flex justify-between\">\n                                  <span className=\"text-desert-stone-dark\">\n                                    AI Time to First Token\n                                  </span>\n                                  <span className=\"font-mono\">\n                                    {latestResult.ai_time_to_first_token?.toFixed(0) || 'N/A'} ms\n                                  </span>\n                                </div>\n                              </>\n                            )}\n                          </div>\n                        </div>\n\n                        {/* Benchmark Info */}\n                        <div>\n                          <h4 className=\"font-semibold text-desert-green mb-3\">Benchmark Info</h4>\n                          <div className=\"space-y-2 text-sm\">\n                            <div className=\"flex justify-between\">\n                              <span className=\"text-desert-stone-dark\">Full Benchmark ID</span>\n                              <span className=\"font-mono text-xs\">{latestResult.benchmark_id}</span>\n                            </div>\n                            <div className=\"flex justify-between\">\n                              <span className=\"text-desert-stone-dark\">Benchmark Type</span>\n                              <span className=\"capitalize\">{latestResult.benchmark_type}</span>\n                            </div>\n                            <div className=\"flex justify-between\">\n                              <span className=\"text-desert-stone-dark\">Run Date</span>\n                              <span>\n                                {new Date(\n                                  latestResult.created_at as unknown as string\n                                ).toLocaleString()}\n                              </span>\n                            </div>\n                            <div className=\"flex justify-between\">\n                              <span className=\"text-desert-stone-dark\">Builder Tag</span>\n                              <span className=\"font-mono\">\n                                {latestResult.builder_tag || 'Not set'}\n                              </span>\n                            </div>\n                            {latestResult.ai_model_used && (\n                              <div className=\"flex justify-between\">\n                                <span className=\"text-desert-stone-dark\">AI Model Used</span>\n                                <span>{latestResult.ai_model_used}</span>\n                              </div>\n                            )}\n                            <div className=\"flex justify-between\">\n                              <span className=\"text-desert-stone-dark\">\n                                Submitted to Repository\n                              </span>\n                              <span>{latestResult.submitted_to_repository ? 'Yes' : 'No'}</span>\n                            </div>\n                            {latestResult.repository_id && (\n                              <div className=\"flex justify-between\">\n                                <span className=\"text-desert-stone-dark\">Repository ID</span>\n                                <span className=\"font-mono text-xs\">\n                                  {latestResult.repository_id}\n                                </span>\n                              </div>\n                            )}\n                          </div>\n                        </div>\n                      </div>\n                    </div>\n                  )}\n                </div>\n              </section>\n\n              {/* Benchmark History */}\n              {benchmarkHistory && benchmarkHistory.length > 1 && (\n                <section className=\"mb-12\">\n                  <h2 className=\"text-2xl font-bold text-desert-green mb-6 flex items-center gap-2\">\n                    <div className=\"w-1 h-6 bg-desert-green\" />\n                    Benchmark History\n                  </h2>\n\n                  <div className=\"bg-desert-white rounded-lg border border-desert-stone-light shadow-sm overflow-hidden\">\n                    <button\n                      onClick={() => setShowHistory(!showHistory)}\n                      className=\"w-full p-4 flex items-center justify-between hover:bg-desert-stone-lighter/30 transition-colors\"\n                    >\n                      <div className=\"flex items-center gap-2\">\n                        <IconClock className=\"w-5 h-5 text-desert-stone-dark\" />\n                        <span className=\"font-medium text-desert-green\">\n                          {benchmarkHistory.length} benchmark\n                          {benchmarkHistory.length !== 1 ? 's' : ''} recorded\n                        </span>\n                      </div>\n                      <IconChevronDown\n                        className={`w-5 h-5 text-desert-stone-dark transition-transform ${showHistory ? 'rotate-180' : ''}`}\n                      />\n                    </button>\n\n                    {showHistory && (\n                      <div className=\"border-t border-desert-stone-light\">\n                        <div className=\"overflow-x-auto\">\n                          <table className=\"w-full text-sm\">\n                            <thead className=\"bg-desert-stone-lighter/50\">\n                              <tr>\n                                <th className=\"text-left p-3 font-medium text-desert-stone-dark\">\n                                  Date\n                                </th>\n                                <th className=\"text-left p-3 font-medium text-desert-stone-dark\">\n                                  Type\n                                </th>\n                                <th className=\"text-left p-3 font-medium text-desert-stone-dark\">\n                                  Score\n                                </th>\n                                <th className=\"text-left p-3 font-medium text-desert-stone-dark\">\n                                  Builder Tag\n                                </th>\n                                <th className=\"text-left p-3 font-medium text-desert-stone-dark\">\n                                  Shared\n                                </th>\n                              </tr>\n                            </thead>\n                            <tbody className=\"divide-y divide-desert-stone-lighter\">\n                              {benchmarkHistory.map((result) => (\n                                <tr\n                                  key={result.benchmark_id}\n                                  className={`hover:bg-desert-stone-lighter/30 ${\n                                    result.benchmark_id === latestResult?.benchmark_id\n                                      ? 'bg-desert-green/5'\n                                      : ''\n                                  }`}\n                                >\n                                  <td className=\"p-3\">\n                                    {new Date(\n                                      result.created_at as unknown as string\n                                    ).toLocaleDateString()}\n                                  </td>\n                                  <td className=\"p-3 capitalize\">{result.benchmark_type}</td>\n                                  <td className=\"p-3\">\n                                    <span className=\"font-bold text-desert-green\">\n                                      {result.nomad_score.toFixed(1)}\n                                    </span>\n                                  </td>\n                                  <td className=\"p-3 font-mono text-xs\">\n                                    {result.builder_tag || '—'}\n                                  </td>\n                                  <td className=\"p-3\">\n                                    {result.submitted_to_repository ? (\n                                      <span className=\"text-green-600\">✓</span>\n                                    ) : (\n                                      <span className=\"text-desert-stone-dark\">—</span>\n                                    )}\n                                  </td>\n                                </tr>\n                              ))}\n                            </tbody>\n                          </table>\n                        </div>\n                      </div>\n                    )}\n                  </div>\n                </section>\n              )}\n            </>\n          )}\n\n          {!latestResult && !isRunning && (\n            <Alert\n              type=\"info\"\n              title=\"No Benchmark Results\"\n              message=\"Run your first benchmark to see your server's performance scores.\"\n              variant=\"bordered\"\n            />\n          )}\n        </main>\n      </div>\n    </SettingsLayout>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/pages/settings/legal.tsx",
    "content": "import { Head } from '@inertiajs/react'\nimport SettingsLayout from '~/layouts/SettingsLayout'\n\nexport default function LegalPage() {\n  return (\n    <SettingsLayout>\n      <Head title=\"Legal Notices | Project N.O.M.A.D.\" />\n      <div className=\"xl:pl-72 w-full\">\n        <main className=\"px-12 py-6 max-w-4xl\">\n          <h1 className=\"text-4xl font-semibold mb-8\">Legal Notices</h1>\n\n          {/* License Agreement */}\n          <section className=\"mb-10\">\n            <h2 className=\"text-2xl font-semibold mb-4\">License Agreement</h2>\n            <p className=\"text-text-primary mb-3\">Copyright 2024-2026 Crosstalk Solutions, LLC</p>\n            <p className=\"text-text-primary mb-3\">\n              Licensed under the Apache License, Version 2.0 (the &quot;License&quot;);\n              you may not use this file except in compliance with the License.\n              You may obtain a copy of the License at\n            </p>\n            <p className=\"text-text-primary mb-3\">\n              <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>\n            </p>\n            <p className=\"text-text-primary\">\n              Unless required by applicable law or agreed to in writing, software\n              distributed under the License is distributed on an &quot;AS IS&quot; BASIS,\n              WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n              See the License for the specific language governing permissions and\n              limitations under the License.\n            </p>\n          </section>\n\n          {/* Third-Party Software */}\n          <section className=\"mb-10\">\n            <h2 className=\"text-2xl font-semibold mb-4\">Third-Party Software Attribution</h2>\n            <p className=\"text-text-primary mb-4\">\n              Project N.O.M.A.D. integrates the following open source projects. We are grateful to\n              their developers and communities:\n            </p>\n            <ul className=\"space-y-3 text-text-primary\">\n              <li>\n                <strong>Kiwix</strong> - Offline Wikipedia and content reader (GPL-3.0 License)\n                <br />\n                <a href=\"https://kiwix.org\" target=\"_blank\" rel=\"noopener noreferrer\" className=\"text-blue-600 hover:underline\">https://kiwix.org</a>\n              </li>\n              <li>\n                <strong>Kolibri</strong> - Offline learning platform by Learning Equality (MIT License)\n                <br />\n                <a href=\"https://learningequality.org/kolibri\" target=\"_blank\" rel=\"noopener noreferrer\" className=\"text-blue-600 hover:underline\">https://learningequality.org/kolibri</a>\n              </li>\n              <li>\n                <strong>Ollama</strong> - Local large language model runtime (MIT License)\n                <br />\n                <a href=\"https://ollama.com\" target=\"_blank\" rel=\"noopener noreferrer\" className=\"text-blue-600 hover:underline\">https://ollama.com</a>\n              </li>\n              <li>\n                <strong>CyberChef</strong> - Data analysis and encoding toolkit by GCHQ (Apache 2.0 License)\n                <br />\n                <a href=\"https://github.com/gchq/CyberChef\" target=\"_blank\" rel=\"noopener noreferrer\" className=\"text-blue-600 hover:underline\">https://github.com/gchq/CyberChef</a>\n              </li>\n              <li>\n                <strong>FlatNotes</strong> - Self-hosted note-taking application (MIT License)\n                <br />\n                <a href=\"https://github.com/dullage/flatnotes\" target=\"_blank\" rel=\"noopener noreferrer\" className=\"text-blue-600 hover:underline\">https://github.com/dullage/flatnotes</a>\n              </li>\n              <li>\n                <strong>Qdrant</strong> - Vector search engine for AI knowledge base (Apache 2.0 License)\n                <br />\n                <a href=\"https://github.com/qdrant/qdrant\" target=\"_blank\" rel=\"noopener noreferrer\" className=\"text-blue-600 hover:underline\">https://github.com/qdrant/qdrant</a>\n              </li>\n            </ul>\n          </section>\n\n          {/* Privacy Statement */}\n          <section className=\"mb-10\">\n            <h2 className=\"text-2xl font-semibold mb-4\">Privacy Statement</h2>\n            <p className=\"text-text-primary mb-3\">\n              Project N.O.M.A.D. is designed with privacy as a core principle:\n            </p>\n            <ul className=\"list-disc list-inside space-y-2 text-text-primary\">\n              <li><strong>Zero Telemetry:</strong> N.O.M.A.D. does not collect, transmit, or store any usage data, analytics, or telemetry.</li>\n              <li><strong>Local-First:</strong> All your data, downloaded content, AI conversations, and notes remain on your device.</li>\n              <li><strong>No Accounts Required:</strong> N.O.M.A.D. operates without user accounts or authentication by default.</li>\n              <li><strong>Network Optional:</strong> An internet connection is only required to download content or updates. All installed features work fully offline.</li>\n            </ul>\n          </section>\n\n          {/* Content Disclaimer */}\n          <section className=\"mb-10\">\n            <h2 className=\"text-2xl font-semibold mb-4\">Content Disclaimer</h2>\n            <p className=\"text-text-primary mb-3\">\n              Project N.O.M.A.D. provides tools to download and access content from third-party sources\n              including Wikipedia, Wikibooks, medical references, educational platforms, and other\n              publicly available resources.\n            </p>\n            <p className=\"text-text-primary mb-3\">\n              Crosstalk Solutions, LLC does not create, control, verify, or guarantee the accuracy,\n              completeness, or reliability of any third-party content. The inclusion of any content\n              does not constitute an endorsement.\n            </p>\n            <p className=\"text-text-primary\">\n              Users are responsible for evaluating the appropriateness and accuracy of any content\n              they download and use.\n            </p>\n          </section>\n\n          {/* Medical Disclaimer */}\n          <section className=\"mb-10\">\n            <h2 className=\"text-2xl font-semibold mb-4\">Medical and Emergency Information Disclaimer</h2>\n            <p className=\"text-text-primary mb-3\">\n              Some content available through N.O.M.A.D. includes medical references, first aid guides,\n              and emergency preparedness information. This content is provided for general\n              informational purposes only.\n            </p>\n            <p className=\"text-text-primary mb-3 font-semibold\">\n              This information is NOT a substitute for professional medical advice, diagnosis, or treatment.\n            </p>\n            <ul className=\"list-disc list-inside space-y-2 text-text-primary mb-3\">\n              <li>Always seek the advice of qualified health providers with questions about medical conditions.</li>\n              <li>Never disregard professional medical advice or delay seeking it because of something you read in offline content.</li>\n              <li>In a medical emergency, call emergency services immediately if available.</li>\n              <li>Medical information may become outdated. Verify critical information with current professional sources when possible.</li>\n            </ul>\n          </section>\n\n          {/* Data Storage Notice */}\n          <section className=\"mb-10\">\n            <h2 className=\"text-2xl font-semibold mb-4\">Data Storage</h2>\n            <p className=\"text-text-primary mb-3\">\n              All data associated with Project N.O.M.A.D. is stored locally on your device:\n            </p>\n            <ul className=\"list-disc list-inside space-y-2 text-text-primary\">\n              <li><strong>Installation Directory:</strong> /opt/project-nomad</li>\n              <li><strong>Downloaded Content:</strong> /opt/project-nomad/storage</li>\n              <li><strong>Application Data:</strong> Stored in Docker volumes on your local system</li>\n            </ul>\n            <p className=\"text-text-primary mt-3\">\n              You maintain full control over your data. Uninstalling N.O.M.A.D. or deleting these\n              directories will permanently remove all associated data.\n            </p>\n          </section>\n\n        </main>\n      </div>\n    </SettingsLayout>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/pages/settings/maps.tsx",
    "content": "import { Head, router } from '@inertiajs/react'\nimport StyledTable from '~/components/StyledTable'\nimport SettingsLayout from '~/layouts/SettingsLayout'\nimport StyledButton from '~/components/StyledButton'\nimport { useModals } from '~/context/ModalContext'\nimport StyledModal from '~/components/StyledModal'\nimport { FileEntry } from '../../../types/files'\nimport { useNotifications } from '~/context/NotificationContext'\nimport { useState } from 'react'\nimport api from '~/lib/api'\nimport DownloadURLModal from '~/components/DownloadURLModal'\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\nimport useDownloads from '~/hooks/useDownloads'\nimport StyledSectionHeader from '~/components/StyledSectionHeader'\nimport CuratedCollectionCard from '~/components/CuratedCollectionCard'\nimport type { CollectionWithStatus } from '../../../types/collections'\nimport ActiveDownloads from '~/components/ActiveDownloads'\nimport Alert from '~/components/Alert'\n\nconst CURATED_COLLECTIONS_KEY = 'curated-map-collections'\n\nexport default function MapsManager(props: {\n  maps: { baseAssetsExist: boolean; regionFiles: FileEntry[] }\n}) {\n  const queryClient = useQueryClient()\n  const { openModal, closeAllModals } = useModals()\n  const { addNotification } = useNotifications()\n  const [downloading, setDownloading] = useState(false)\n\n  const { data: curatedCollections } = useQuery({\n    queryKey: [CURATED_COLLECTIONS_KEY],\n    queryFn: () => api.listCuratedMapCollections(),\n    refetchOnWindowFocus: false,\n  })\n\n  const { invalidate: invalidateDownloads } = useDownloads({\n    filetype: 'map',\n    enabled: true,\n  })\n\n  async function downloadBaseAssets() {\n    try {\n      setDownloading(true)\n\n      const res = await api.downloadBaseMapAssets()\n      if (!res) {\n        throw new Error('An unknown error occurred while downloading base assets.')\n      }\n\n      if (res.success) {\n        addNotification({\n          type: 'success',\n          message: 'Base map assets downloaded successfully.',\n        })\n        router.reload()\n      }\n    } catch (error) {\n      console.error('Error downloading base assets:', error)\n      addNotification({\n        type: 'error',\n        message: 'An error occurred while downloading the base map assets. Please try again.',\n      })\n    } finally {\n      setDownloading(false)\n    }\n  }\n\n  async function downloadCollection(record: CollectionWithStatus) {\n    try {\n      await api.downloadMapCollection(record.slug)\n      invalidateDownloads()\n      addNotification({\n        type: 'success',\n        message: `Download for collection \"${record.name}\" has been queued.`,\n      })\n    } catch (error) {\n      console.error('Error downloading collection:', error)\n    }\n  }\n\n  async function downloadCustomFile(url: string) {\n    try {\n      await api.downloadRemoteMapRegion(url)\n      invalidateDownloads()\n      addNotification({\n        type: 'success',\n        message: 'Download has been queued.',\n      })\n    } catch (error) {\n      console.error('Error downloading custom file:', error)\n    }\n  }\n\n  async function confirmDeleteFile(file: FileEntry) {\n    openModal(\n      <StyledModal\n        title=\"Confirm Delete?\"\n        onConfirm={() => {\n          closeAllModals()\n        }}\n        onCancel={closeAllModals}\n        open={true}\n        confirmText=\"Delete\"\n        cancelText=\"Cancel\"\n        confirmVariant=\"danger\"\n      >\n        <p className=\"text-text-secondary\">\n          Are you sure you want to delete {file.name}? This action cannot be undone.\n        </p>\n      </StyledModal>,\n      'confirm-delete-file-modal'\n    )\n  }\n\n  async function confirmDownload(record: CollectionWithStatus) {\n    const isCollection = 'resources' in record\n    openModal(\n      <StyledModal\n        title=\"Confirm Download?\"\n        onConfirm={() => {\n          if (isCollection) {\n            if (record.all_installed) {\n              addNotification({\n                message: `All resources in the collection \"${record.name}\" have already been downloaded.`,\n                type: 'info',\n              })\n              return\n            }\n            downloadCollection(record)\n          }\n          closeAllModals()\n        }}\n        onCancel={closeAllModals}\n        open={true}\n        confirmText=\"Download\"\n        cancelText=\"Cancel\"\n        confirmVariant=\"primary\"\n      >\n        <p className=\"text-text-secondary\">\n          Are you sure you want to download <strong>{isCollection ? record.name : record}</strong>?\n          It may take some time for it to be available depending on the file size and your internet\n          connection.\n        </p>\n      </StyledModal>,\n      'confirm-download-file-modal'\n    )\n  }\n\n  async function openDownloadModal() {\n    openModal(\n      <DownloadURLModal\n        title=\"Download Map File\"\n        suggestedURL=\"e.g. https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/california.pmtiles\"\n        onCancel={() => closeAllModals()}\n        onPreflightSuccess={async (url) => {\n          await downloadCustomFile(url)\n          closeAllModals()\n        }}\n      />,\n      'download-map-file-modal'\n    )\n  }\n\n  const refreshManifests = useMutation({\n    mutationFn: () => api.refreshManifests(),\n    onSuccess: () => {\n      addNotification({\n        message: 'Successfully refreshed map collections.',\n        type: 'success',\n      })\n      queryClient.invalidateQueries({ queryKey: [CURATED_COLLECTIONS_KEY] })\n    },\n  })\n\n  return (\n    <SettingsLayout>\n      <Head title=\"Maps Manager\" />\n      <div className=\"xl:pl-72 w-full\">\n        <main className=\"px-12 py-6\">\n          <div className=\"flex items-center justify-between\">\n            <div className=\"flex flex-col\">\n              <h1 className=\"text-4xl font-semibold mb-2\">Maps Manager</h1>\n              <p className=\"text-text-muted\">Manage your stored map files and explore new regions!</p>\n            </div>\n            <div className=\"flex space-x-4\">\n\n            </div>\n          </div>\n          {!props.maps.baseAssetsExist && (\n            <Alert\n              title=\"The base map assets have not been installed. Please download them first to enable map functionality.\"\n              type=\"warning\"\n              variant=\"solid\"\n              className=\"my-4\"\n              buttonProps={{\n                variant: 'secondary',\n                children: 'Download Base Assets',\n                icon: 'IconDownload',\n                loading: downloading,\n                onClick: () => downloadBaseAssets(),\n              }}\n            />\n          )}\n          <div className=\"mt-8 mb-6 flex items-center justify-between\">\n            <StyledSectionHeader title=\"Curated Map Regions\" className=\"!mb-0\" />\n            <StyledButton\n              onClick={() => refreshManifests.mutate()}\n              disabled={refreshManifests.isPending}\n              icon=\"IconRefresh\"\n            >\n              Force Refresh Collections\n            </StyledButton>\n          </div>\n          <div className=\"!mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8\">\n            {curatedCollections?.map((collection) => (\n              <CuratedCollectionCard\n                key={collection.slug}\n                collection={collection}\n                onClick={(collection) => confirmDownload(collection)}\n              />\n            ))}\n            {curatedCollections && curatedCollections.length === 0 && (\n              <p className=\"text-text-muted\">No curated collections available.</p>\n            )}\n          </div>\n          <div className=\"mt-12 mb-6 flex items-center justify-between\">\n            <StyledSectionHeader title=\"Stored Map Files\" className=\"!mb-0\" />\n            <StyledButton\n              variant=\"primary\"\n              onClick={openDownloadModal}\n              loading={downloading}\n              icon=\"IconCloudDownload\"\n            >\n              Download a Custom Map File\n            </StyledButton>\n          </div>\n          <StyledTable<FileEntry & { actions?: any }>\n            className=\"font-semibold mt-4\"\n            rowLines={true}\n            loading={false}\n            compact\n            columns={[\n              { accessor: 'name', title: 'Name' },\n              {\n                accessor: 'actions',\n                title: 'Actions',\n                render: (record) => (\n                  <div className=\"flex space-x-2\">\n                    <StyledButton\n                      variant=\"danger\"\n                      icon={'IconTrash'}\n                      onClick={() => {\n                        confirmDeleteFile(record)\n                      }}\n                    >\n                      Delete\n                    </StyledButton>\n                  </div>\n                ),\n              },\n            ]}\n            data={props.maps.regionFiles || []}\n          />\n          <ActiveDownloads filetype=\"map\" withHeader />\n        </main>\n      </div>\n    </SettingsLayout>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/pages/settings/models.tsx",
    "content": "import { Head, router, usePage } from '@inertiajs/react'\nimport { useRef, useState } from 'react'\nimport StyledTable from '~/components/StyledTable'\nimport SettingsLayout from '~/layouts/SettingsLayout'\nimport { NomadOllamaModel } from '../../../types/ollama'\nimport StyledButton from '~/components/StyledButton'\nimport useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'\nimport Alert from '~/components/Alert'\nimport { useNotifications } from '~/context/NotificationContext'\nimport api from '~/lib/api'\nimport { useModals } from '~/context/ModalContext'\nimport StyledModal from '~/components/StyledModal'\nimport { ModelResponse } from 'ollama'\nimport { SERVICE_NAMES } from '../../../constants/service_names'\nimport Switch from '~/components/inputs/Switch'\nimport StyledSectionHeader from '~/components/StyledSectionHeader'\nimport { useMutation, useQuery } from '@tanstack/react-query'\nimport Input from '~/components/inputs/Input'\nimport { IconSearch, IconRefresh } from '@tabler/icons-react'\nimport useDebounce from '~/hooks/useDebounce'\nimport ActiveModelDownloads from '~/components/ActiveModelDownloads'\nimport { useSystemInfo } from '~/hooks/useSystemInfo'\n\nexport default function ModelsPage(props: {\n  models: {\n    availableModels: NomadOllamaModel[]\n    installedModels: ModelResponse[]\n    settings: { chatSuggestionsEnabled: boolean; aiAssistantCustomName: string }\n  }\n}) {\n  const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props\n  const { isInstalled } = useServiceInstalledStatus(SERVICE_NAMES.OLLAMA)\n  const { addNotification } = useNotifications()\n  const { openModal, closeAllModals } = useModals()\n  const { debounce } = useDebounce()\n  const { data: systemInfo } = useSystemInfo({})\n\n  const [gpuBannerDismissed, setGpuBannerDismissed] = useState(() => {\n    try {\n      return localStorage.getItem('nomad:gpu-banner-dismissed') === 'true'\n    } catch {\n      return false\n    }\n  })\n  const [reinstalling, setReinstalling] = useState(false)\n\n  const handleDismissGpuBanner = () => {\n    setGpuBannerDismissed(true)\n    try {\n      localStorage.setItem('nomad:gpu-banner-dismissed', 'true')\n    } catch {}\n  }\n\n  const handleForceReinstallOllama = () => {\n    openModal(\n      <StyledModal\n        title=\"Reinstall AI Assistant?\"\n        onConfirm={async () => {\n          closeAllModals()\n          setReinstalling(true)\n          try {\n            const response = await api.forceReinstallService('nomad_ollama')\n            if (!response || !response.success) {\n              throw new Error(response?.message || 'Force reinstall failed')\n            }\n            addNotification({\n              message: `${aiAssistantName} is being reinstalled with GPU support. This page will reload shortly.`,\n              type: 'success',\n            })\n            try { localStorage.removeItem('nomad:gpu-banner-dismissed') } catch {}\n            setTimeout(() => window.location.reload(), 5000)\n          } catch (error) {\n            addNotification({\n              message: `Failed to reinstall: ${error instanceof Error ? error.message : 'Unknown error'}`,\n              type: 'error',\n            })\n            setReinstalling(false)\n          }\n        }}\n        onCancel={closeAllModals}\n        open={true}\n        confirmText=\"Reinstall\"\n        cancelText=\"Cancel\"\n      >\n        <p className=\"text-text-primary\">\n          This will recreate the {aiAssistantName} container with GPU support enabled.\n          Your downloaded models will be preserved. The service will be briefly\n          unavailable during reinstall.\n        </p>\n      </StyledModal>,\n      'gpu-health-force-reinstall-modal'\n    )\n  }\n  const [chatSuggestionsEnabled, setChatSuggestionsEnabled] = useState(\n    props.models.settings.chatSuggestionsEnabled\n  )\n  const [aiAssistantCustomName, setAiAssistantCustomName] = useState(\n    props.models.settings.aiAssistantCustomName\n  )\n\n  const [query, setQuery] = useState('')\n  const [queryUI, setQueryUI] = useState('')\n  const [limit, setLimit] = useState(15)\n\n  const debouncedSetQuery = debounce((val: string) => {\n    setQuery(val)\n  }, 300)\n\n  const forceRefreshRef = useRef(false)\n  const [isForceRefreshing, setIsForceRefreshing] = useState(false)\n\n  const { data: availableModelData, isFetching, refetch } = useQuery({\n    queryKey: ['ollama', 'availableModels', query, limit],\n    queryFn: async () => {\n      const force = forceRefreshRef.current\n      forceRefreshRef.current = false\n      const res = await api.getAvailableModels({\n        query,\n        recommendedOnly: false,\n        limit,\n        force: force || undefined,\n      })\n      if (!res) {\n        return {\n          models: [],\n          hasMore: false,\n        }\n      }\n      return res\n    },\n    initialData: { models: props.models.availableModels, hasMore: false },\n  })\n\n  async function handleForceRefresh() {\n    forceRefreshRef.current = true\n    setIsForceRefreshing(true)\n    await refetch()\n    setIsForceRefreshing(false)\n    addNotification({ message: 'Model list refreshed from remote.', type: 'success' })\n  }\n\n  async function handleInstallModel(modelName: string) {\n    try {\n      const res = await api.downloadModel(modelName)\n      if (res.success) {\n        addNotification({\n          message: `Model download initiated for ${modelName}. It may take some time to complete.`,\n          type: 'success',\n        })\n      }\n    } catch (error) {\n      console.error('Error installing model:', error)\n      addNotification({\n        message: `There was an error installing the model: ${modelName}. Please try again.`,\n        type: 'error',\n      })\n    }\n  }\n\n  async function handleDeleteModel(modelName: string) {\n    try {\n      const res = await api.deleteModel(modelName)\n      if (res.success) {\n        addNotification({\n          message: `Model deleted: ${modelName}.`,\n          type: 'success',\n        })\n      }\n      closeAllModals()\n      router.reload()\n    } catch (error) {\n      console.error('Error deleting model:', error)\n      addNotification({\n        message: `There was an error deleting the model: ${modelName}. Please try again.`,\n        type: 'error',\n      })\n    }\n  }\n\n  async function confirmDeleteModel(model: string) {\n    openModal(\n      <StyledModal\n        title=\"Delete Model?\"\n        onConfirm={() => {\n          handleDeleteModel(model)\n        }}\n        onCancel={closeAllModals}\n        open={true}\n        confirmText=\"Delete\"\n        cancelText=\"Cancel\"\n        confirmVariant=\"primary\"\n      >\n        <p className=\"text-text-primary\">\n          Are you sure you want to delete this model? You will need to download it again if you want\n          to use it in the future.\n        </p>\n      </StyledModal>,\n      'confirm-delete-model-modal'\n    )\n  }\n\n  const updateSettingMutation = useMutation({\n    mutationFn: async ({ key, value }: { key: string; value: boolean | string }) => {\n      return await api.updateSetting(key, value)\n    },\n    onSuccess: () => {\n      addNotification({\n        message: 'Setting updated successfully.',\n        type: 'success',\n      })\n    },\n    onError: (error) => {\n      console.error('Error updating setting:', error)\n      addNotification({\n        message: 'There was an error updating the setting. Please try again.',\n        type: 'error',\n      })\n    },\n  })\n\n  return (\n    <SettingsLayout>\n      <Head title={`${aiAssistantName} Settings | Project N.O.M.A.D.`} />\n      <div className=\"xl:pl-72 w-full\">\n        <main className=\"px-12 py-6\">\n          <h1 className=\"text-4xl font-semibold mb-4\">{aiAssistantName}</h1>\n          <p className=\"text-text-muted mb-4\">\n            Easily manage the {aiAssistantName}'s settings and installed models. We recommend\n            starting with smaller models first to see how they perform on your system before moving\n            on to larger ones.\n          </p>\n          {!isInstalled && (\n            <Alert\n              title={`${aiAssistantName}'s dependencies are not installed. Please install them to manage AI models.`}\n              type=\"warning\"\n              variant=\"solid\"\n              className=\"!mt-6\"\n            />\n          )}\n          {isInstalled && systemInfo?.gpuHealth?.status === 'passthrough_failed' && !gpuBannerDismissed && (\n            <Alert\n              type=\"warning\"\n              variant=\"bordered\"\n              title=\"GPU Not Accessible\"\n              message={`Your system has an NVIDIA GPU, but ${aiAssistantName} can't access it. AI is running on CPU only, which is significantly slower.`}\n              className=\"!mt-6\"\n              dismissible={true}\n              onDismiss={handleDismissGpuBanner}\n              buttonProps={{\n                children: `Fix: Reinstall ${aiAssistantName}`,\n                icon: 'IconRefresh',\n                variant: 'action',\n                size: 'sm',\n                onClick: handleForceReinstallOllama,\n                loading: reinstalling,\n                disabled: reinstalling,\n              }}\n            />\n          )}\n\n          <StyledSectionHeader title=\"Settings\" className=\"mt-8 mb-4\" />\n          <div className=\"bg-surface-primary rounded-lg border-2 border-border-subtle p-6\">\n            <div className=\"space-y-4\">\n              <Switch\n                checked={chatSuggestionsEnabled}\n                onChange={(newVal) => {\n                  setChatSuggestionsEnabled(newVal)\n                  updateSettingMutation.mutate({ key: 'chat.suggestionsEnabled', value: newVal })\n                }}\n                label=\"Chat Suggestions\"\n                description=\"Display AI-generated conversation starters in the chat interface\"\n              />\n              <Input\n                name=\"aiAssistantCustomName\"\n                label=\"Assistant Name\"\n                helpText='Give your AI assistant a custom name that will be used in the chat interface and other areas of the application.'\n                placeholder=\"AI Assistant\"\n                value={aiAssistantCustomName}\n                onChange={(e) => setAiAssistantCustomName(e.target.value)}\n                onBlur={() =>\n                  updateSettingMutation.mutate({\n                    key: 'ai.assistantCustomName',\n                    value: aiAssistantCustomName,\n                  })\n                }\n              />\n            </div>\n          </div>\n          <ActiveModelDownloads withHeader />\n\n          <StyledSectionHeader title=\"Models\" className=\"mt-12 mb-4\" />\n          <div className=\"flex justify-start items-center gap-3 mt-4\">\n            <Input\n              name=\"search\"\n              label=\"\"\n              placeholder=\"Search language models..\"\n              value={queryUI}\n              onChange={(e) => {\n                setQueryUI(e.target.value)\n                debouncedSetQuery(e.target.value)\n              }}\n              className=\"w-1/3\"\n              leftIcon={<IconSearch className=\"w-5 h-5 text-text-muted\" />}\n            />\n            <StyledButton\n              variant=\"secondary\"\n              onClick={handleForceRefresh}\n              icon=\"IconRefresh\"\n              loading={isForceRefreshing}\n              className='mt-1'\n            >\n              Refresh Models\n            </StyledButton>\n          </div>\n          <StyledTable<NomadOllamaModel>\n            className=\"font-semibold mt-4\"\n            rowLines={true}\n            columns={[\n              {\n                accessor: 'name',\n                title: 'Name',\n                render(record) {\n                  return (\n                    <div className=\"flex flex-col\">\n                      <p className=\"text-lg font-semibold\">{record.name}</p>\n                      <p className=\"text-sm text-text-muted\">{record.description}</p>\n                    </div>\n                  )\n                },\n              },\n              {\n                accessor: 'estimated_pulls',\n                title: 'Estimated Pulls',\n              },\n              {\n                accessor: 'model_last_updated',\n                title: 'Last Updated',\n              },\n            ]}\n            data={availableModelData?.models || []}\n            loading={isFetching}\n            expandable={{\n              expandedRowRender: (record) => (\n                <div className=\"pl-14\">\n                  <div className=\"bg-surface-primary overflow-hidden\">\n                    <table className=\"min-w-full divide-y divide-border-subtle\">\n                      <thead className=\"bg-surface-primary\">\n                        <tr>\n                          <th className=\"px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider\">\n                            Tag\n                          </th>\n                          <th className=\"px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider\">\n                            Input Type\n                          </th>\n                          <th className=\"px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider\">\n                            Context Size\n                          </th>\n                          <th className=\"px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider\">\n                            Model Size\n                          </th>\n                          <th className=\"px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider\">\n                            Action\n                          </th>\n                        </tr>\n                      </thead>\n                      <tbody className=\"bg-surface-primary divide-y divide-border-subtle\">\n                        {record.tags.map((tag, tagIndex) => {\n                          const isInstalled = props.models.installedModels.some(\n                            (mod) => mod.name === tag.name\n                          )\n                          return (\n                            <tr key={tagIndex} className=\"hover:bg-surface-secondary\">\n                              <td className=\"px-6 py-4 whitespace-nowrap\">\n                                <span className=\"text-sm font-medium text-text-primary\">\n                                  {tag.name}\n                                </span>\n                              </td>\n                              <td className=\"px-6 py-4 whitespace-nowrap\">\n                                <span className=\"text-sm text-text-secondary\">{tag.input || 'N/A'}</span>\n                              </td>\n                              <td className=\"px-6 py-4 whitespace-nowrap\">\n                                <span className=\"text-sm text-text-secondary\">\n                                  {tag.context || 'N/A'}\n                                </span>\n                              </td>\n                              <td className=\"px-6 py-4 whitespace-nowrap\">\n                                <span className=\"text-sm text-text-secondary\">{tag.size || 'N/A'}</span>\n                              </td>\n                              <td className=\"px-6 py-4 whitespace-nowrap\">\n                                <StyledButton\n                                  variant={isInstalled ? 'danger' : 'primary'}\n                                  onClick={() => {\n                                    if (!isInstalled) {\n                                      handleInstallModel(tag.name)\n                                    } else {\n                                      confirmDeleteModel(tag.name)\n                                    }\n                                  }}\n                                  icon={isInstalled ? 'IconTrash' : 'IconDownload'}\n                                >\n                                  {isInstalled ? 'Delete' : 'Install'}\n                                </StyledButton>\n                              </td>\n                            </tr>\n                          )\n                        })}\n                      </tbody>\n                    </table>\n                  </div>\n                </div>\n              ),\n            }}\n          />\n          <div className=\"flex justify-center mt-6\">\n            {availableModelData?.hasMore && (\n              <StyledButton\n                variant=\"primary\"\n                onClick={() => {\n                  setLimit((prev) => prev + 15)\n                }}\n              >\n                Load More\n              </StyledButton>\n            )}\n          </div>\n        </main>\n      </div>\n    </SettingsLayout>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/pages/settings/support.tsx",
    "content": "import { Head } from '@inertiajs/react'\nimport { IconExternalLink } from '@tabler/icons-react'\nimport SettingsLayout from '~/layouts/SettingsLayout'\n\nexport default function SupportPage() {\n  return (\n    <SettingsLayout>\n      <Head title=\"Support the Project | Project N.O.M.A.D.\" />\n      <div className=\"xl:pl-72 w-full\">\n        <main className=\"px-12 py-6 max-w-4xl\">\n          <h1 className=\"text-4xl font-semibold mb-4\">Support the Project</h1>\n          <p className=\"text-text-muted mb-10 text-lg\">\n            Project NOMAD is 100% free and open source — no subscriptions, no paywalls, no catch.\n            If you'd like to help keep the project going, here are a few ways to show your support.\n          </p>\n\n          {/* Ko-fi */}\n          <section className=\"mb-12\">\n            <h2 className=\"text-2xl font-semibold mb-3\">Buy Us a Coffee</h2>\n            <p className=\"text-text-muted mb-4\">\n              Every contribution helps fund development, server costs, and new content packs for NOMAD.\n              Even a small donation goes a long way.\n            </p>\n            <a\n              href=\"https://ko-fi.com/crosstalk\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              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\"\n            >\n              Support on Ko-fi\n              <IconExternalLink size={18} />\n            </a>\n          </section>\n\n          {/* Rogue Support */}\n          <section className=\"mb-12\">\n            <h2 className=\"text-2xl font-semibold mb-3\">Need Help With Your Home Network?</h2>\n            <a\n              href=\"https://roguesupport.com\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"block mb-4 rounded-lg overflow-hidden hover:opacity-90 transition-opacity\"\n            >\n              <img\n                src=\"/rogue-support-banner.png\"\n                alt=\"Rogue Support — Conquer Your Home Network\"\n                className=\"w-full\"\n              />\n            </a>\n            <p className=\"text-text-muted mb-4\">\n              Rogue Support is a networking consultation service for home users.\n              Think of it as Uber for computer networking — expert help when you need it.\n            </p>\n            <a\n              href=\"https://roguesupport.com\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"inline-flex items-center gap-2 text-blue-600 hover:underline font-medium\"\n            >\n              Visit RogueSupport.com\n              <IconExternalLink size={16} />\n            </a>\n          </section>\n\n          {/* Other Ways to Help */}\n          <section className=\"mb-10\">\n            <h2 className=\"text-2xl font-semibold mb-3\">Other Ways to Help</h2>\n            <ul className=\"space-y-2 text-text-muted\">\n              <li>\n                <a\n                  href=\"https://github.com/Crosstalk-Solutions/project-nomad\"\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                  className=\"text-blue-600 hover:underline\"\n                >\n                  Star the project on GitHub\n                </a>\n                {' '}— it helps more people discover NOMAD\n              </li>\n              <li>\n                <a\n                  href=\"https://github.com/Crosstalk-Solutions/project-nomad/issues\"\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                  className=\"text-blue-600 hover:underline\"\n                >\n                  Report bugs and suggest features\n                </a>\n                {' '}— every report makes NOMAD better\n              </li>\n              <li>Share NOMAD with someone who'd use it — word of mouth is the best marketing</li>\n              <li>\n                <a\n                  href=\"https://discord.com/invite/crosstalksolutions\"\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                  className=\"text-blue-600 hover:underline\"\n                >\n                  Join the Discord community\n                </a>\n                {' '}— hang out, share your build, help other users\n              </li>\n            </ul>\n          </section>\n\n        </main>\n      </div>\n    </SettingsLayout>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/pages/settings/system.tsx",
    "content": "import { useState } from 'react'\nimport { Head } from '@inertiajs/react'\nimport SettingsLayout from '~/layouts/SettingsLayout'\nimport { SystemInformationResponse } from '../../../types/system'\nimport { formatBytes } from '~/lib/util'\nimport { getAllDiskDisplayItems } from '~/hooks/useDiskDisplayData'\nimport CircularGauge from '~/components/systeminfo/CircularGauge'\nimport HorizontalBarChart from '~/components/HorizontalBarChart'\nimport InfoCard from '~/components/systeminfo/InfoCard'\nimport Alert from '~/components/Alert'\nimport StyledModal from '~/components/StyledModal'\nimport { useSystemInfo } from '~/hooks/useSystemInfo'\nimport { useNotifications } from '~/context/NotificationContext'\nimport { useModals } from '~/context/ModalContext'\nimport api from '~/lib/api'\nimport StatusCard from '~/components/systeminfo/StatusCard'\nimport { IconCpu, IconDatabase, IconServer, IconDeviceDesktop, IconComponents } from '@tabler/icons-react'\n\nexport default function SettingsPage(props: {\n  system: { info: SystemInformationResponse | undefined }\n}) {\n  const { data: info } = useSystemInfo({\n    initialData: props.system.info,\n  })\n  const { addNotification } = useNotifications()\n  const { openModal, closeAllModals } = useModals()\n\n  const [gpuBannerDismissed, setGpuBannerDismissed] = useState(() => {\n    try {\n      return localStorage.getItem('nomad:gpu-banner-dismissed') === 'true'\n    } catch {\n      return false\n    }\n  })\n  const [reinstalling, setReinstalling] = useState(false)\n\n  const handleDismissGpuBanner = () => {\n    setGpuBannerDismissed(true)\n    try {\n      localStorage.setItem('nomad:gpu-banner-dismissed', 'true')\n    } catch {}\n  }\n\n  const handleForceReinstallOllama = () => {\n    openModal(\n      <StyledModal\n        title=\"Reinstall AI Assistant?\"\n        onConfirm={async () => {\n          closeAllModals()\n          setReinstalling(true)\n          try {\n            const response = await api.forceReinstallService('nomad_ollama')\n            if (!response || !response.success) {\n              throw new Error(response?.message || 'Force reinstall failed')\n            }\n            addNotification({\n              message: 'AI Assistant is being reinstalled with GPU support. This page will reload shortly.',\n              type: 'success',\n            })\n            try { localStorage.removeItem('nomad:gpu-banner-dismissed') } catch {}\n            setTimeout(() => window.location.reload(), 5000)\n          } catch (error) {\n            addNotification({\n              message: `Failed to reinstall: ${error instanceof Error ? error.message : 'Unknown error'}`,\n              type: 'error',\n            })\n            setReinstalling(false)\n          }\n        }}\n        onCancel={closeAllModals}\n        open={true}\n        confirmText=\"Reinstall\"\n        cancelText=\"Cancel\"\n      >\n        <p className=\"text-text-primary\">\n          This will recreate the AI Assistant container with GPU support enabled.\n          Your downloaded models will be preserved. The service will be briefly\n          unavailable during reinstall.\n        </p>\n      </StyledModal>,\n      'gpu-health-force-reinstall-modal'\n    )\n  }\n\n  // Use (total - available) to reflect actual memory pressure.\n  // mem.used includes reclaimable buff/cache on Linux, which inflates the number.\n  const memoryUsed = info?.mem.total && info?.mem.available != null\n    ? info.mem.total - info.mem.available\n    : info?.mem.used || 0\n  const memoryUsagePercent = info?.mem.total\n    ? ((memoryUsed / info.mem.total) * 100).toFixed(1)\n    : 0\n\n  const swapUsagePercent = info?.mem.swaptotal\n    ? ((info.mem.swapused / info.mem.swaptotal) * 100).toFixed(1)\n    : 0\n\n  const uptimeSeconds = info?.uptime.uptime || 0\n  const uptimeDays = Math.floor(uptimeSeconds / 86400)\n  const uptimeHours = Math.floor((uptimeSeconds % 86400) / 3600)\n  const uptimeMinutes = Math.floor((uptimeSeconds % 3600) / 60)\n  const uptimeDisplay = uptimeDays > 0\n    ? `${uptimeDays}d ${uptimeHours}h ${uptimeMinutes}m`\n    : uptimeHours > 0\n      ? `${uptimeHours}h ${uptimeMinutes}m`\n      : `${uptimeMinutes}m`\n\n  // Build storage display items - fall back to fsSize when disk array is empty\n  const storageItems = getAllDiskDisplayItems(info?.disk, info?.fsSize)\n\n  return (\n    <SettingsLayout>\n      <Head title=\"System Information\" />\n      <div className=\"xl:pl-72 w-full\">\n        <main className=\"px-6 lg:px-12 py-6 lg:py-8\">\n          <div className=\"mb-8\">\n            <h1 className=\"text-4xl font-bold text-desert-green mb-2\">System Information</h1>\n            <p className=\"text-desert-stone-dark\">\n              Real-time monitoring and diagnostics • Last updated: {new Date().toLocaleString()} •\n              Refreshing data every 30 seconds\n            </p>\n          </div>\n          {Number(memoryUsagePercent) > 90 && (\n            <div className=\"mb-6\">\n              <Alert\n                type=\"error\"\n                title=\"Very High Memory Usage Detected\"\n                message=\"System memory usage exceeds 90%. Performance degradation may occur.\"\n                variant=\"bordered\"\n              />\n            </div>\n          )}\n          <section className=\"mb-12\">\n            <h2 className=\"text-2xl font-bold text-desert-green mb-6 flex items-center gap-2\">\n              <div className=\"w-1 h-6 bg-desert-green\" />\n              Resource Usage\n            </h2>\n\n            <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8\">\n              <div className=\"bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow\">\n                <CircularGauge\n                  value={info?.currentLoad.currentLoad || 0}\n                  label=\"CPU Usage\"\n                  size=\"lg\"\n                  variant=\"cpu\"\n                  subtext={`${info?.cpu.cores || 0} cores`}\n                  icon={<IconCpu className=\"w-8 h-8\" />}\n                />\n              </div>\n              <div className=\"bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow\">\n                <CircularGauge\n                  value={Number(memoryUsagePercent)}\n                  label=\"Memory Usage\"\n                  size=\"lg\"\n                  variant=\"memory\"\n                  subtext={`${formatBytes(memoryUsed)} / ${formatBytes(info?.mem.total || 0)}`}\n                  icon={<IconDatabase className=\"w-8 h-8\" />}\n                />\n              </div>\n              <div className=\"bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow\">\n                <CircularGauge\n                  value={Number(swapUsagePercent)}\n                  label=\"Swap Usage\"\n                  size=\"lg\"\n                  variant=\"disk\"\n                  subtext={`${formatBytes(info?.mem.swapused || 0)} / ${formatBytes(info?.mem.swaptotal || 0)}`}\n                  icon={<IconServer className=\"w-8 h-8\" />}\n                />\n              </div>\n            </div>\n          </section>\n          <section className=\"mb-12\">\n            <h2 className=\"text-2xl font-bold text-desert-green mb-6 flex items-center gap-2\">\n              <div className=\"w-1 h-6 bg-desert-green\" />\n              System Details\n            </h2>\n\n            <div className=\"grid grid-cols-1 lg:grid-cols-2 gap-6\">\n              <InfoCard\n                title=\"Operating System\"\n                icon={<IconDeviceDesktop className=\"w-6 h-6\" />}\n                variant=\"elevated\"\n                data={[\n                  { label: 'Distribution', value: info?.os.distro },\n                  { label: 'Kernel Version', value: info?.os.kernel },\n                  { label: 'Architecture', value: info?.os.arch },\n                  { label: 'Hostname', value: info?.os.hostname },\n                  { label: 'Platform', value: info?.os.platform },\n                ]}\n              />\n              <InfoCard\n                title=\"Processor\"\n                icon={<IconCpu className=\"w-6 h-6\" />}\n                variant=\"elevated\"\n                data={[\n                  { label: 'Manufacturer', value: info?.cpu.manufacturer },\n                  { label: 'Brand', value: info?.cpu.brand },\n                  { label: 'Cores', value: info?.cpu.cores },\n                  { label: 'Physical Cores', value: info?.cpu.physicalCores },\n                  {\n                    label: 'Virtualization',\n                    value: info?.cpu.virtualization ? 'Enabled' : 'Disabled',\n                  },\n                ]}\n              />\n              {info?.gpuHealth?.status === 'passthrough_failed' && !gpuBannerDismissed && (\n                <div className=\"lg:col-span-2\">\n                  <Alert\n                    type=\"warning\"\n                    variant=\"bordered\"\n                    title=\"GPU Not Accessible to AI Assistant\"\n                    message=\"Your system has an NVIDIA GPU, but the AI Assistant can't access it. AI is running on CPU only, which is significantly slower.\"\n                    dismissible={true}\n                    onDismiss={handleDismissGpuBanner}\n                    buttonProps={{\n                      children: 'Fix: Reinstall AI Assistant',\n                      icon: 'IconRefresh',\n                      variant: 'action',\n                      size: 'sm',\n                      onClick: handleForceReinstallOllama,\n                      loading: reinstalling,\n                      disabled: reinstalling,\n                    }}\n                  />\n                </div>\n              )}\n              {info?.graphics?.controllers && info.graphics.controllers.length > 0 && (\n                <InfoCard\n                  title=\"Graphics\"\n                  icon={<IconComponents className=\"w-6 h-6\" />}\n                  variant=\"elevated\"\n                  data={info.graphics.controllers.map((gpu, i) => {\n                    const prefix = info.graphics.controllers.length > 1 ? `GPU ${i + 1} ` : ''\n                    return [\n                      { label: `${prefix}Model`, value: gpu.model },\n                      { label: `${prefix}Vendor`, value: gpu.vendor },\n                      { label: `${prefix}VRAM`, value: gpu.vram ? `${gpu.vram} MB` : 'N/A' },\n                    ]\n                  }).flat()}\n                />\n              )}\n            </div>\n          </section>\n          <section className=\"mb-12\">\n            <h2 className=\"text-2xl font-bold text-desert-green mb-6 flex items-center gap-2\">\n              <div className=\"w-1 h-6 bg-desert-green\" />\n              Memory Allocation\n            </h2>\n            <div className=\"bg-desert-white rounded-lg p-8 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow\">\n              <div className=\"grid grid-cols-1 md:grid-cols-3 gap-8 mb-8\">\n                <div className=\"text-center\">\n                  <div className=\"text-3xl font-bold text-desert-green mb-1\">\n                    {formatBytes(info?.mem.total || 0)}\n                  </div>\n                  <div className=\"text-sm text-desert-stone-dark uppercase tracking-wide\">\n                    Total RAM\n                  </div>\n                </div>\n                <div className=\"text-center\">\n                  <div className=\"text-3xl font-bold text-desert-green mb-1\">\n                    {formatBytes(memoryUsed)}\n                  </div>\n                  <div className=\"text-sm text-desert-stone-dark uppercase tracking-wide\">\n                    Used RAM\n                  </div>\n                </div>\n                <div className=\"text-center\">\n                  <div className=\"text-3xl font-bold text-desert-green mb-1\">\n                    {formatBytes(info?.mem.available || 0)}\n                  </div>\n                  <div className=\"text-sm text-desert-stone-dark uppercase tracking-wide\">\n                    Available RAM\n                  </div>\n                </div>\n              </div>\n              <div className=\"relative h-12 bg-desert-stone-lighter rounded-lg overflow-hidden border border-desert-stone-light\">\n                <div\n                  className=\"absolute left-0 top-0 h-full bg-desert-orange transition-all duration-1000\"\n                  style={{ width: `${memoryUsagePercent}%` }}\n                ></div>\n                <div className=\"absolute inset-0 flex items-center justify-center\">\n                  <span className=\"text-sm font-bold text-white drop-shadow-md z-10\">\n                    {memoryUsagePercent}% Utilized\n                  </span>\n                </div>\n              </div>\n            </div>\n          </section>\n          <section className=\"mb-12\">\n            <h2 className=\"text-2xl font-bold text-desert-green mb-6 flex items-center gap-2\">\n              <div className=\"w-1 h-6 bg-desert-green\" />\n              Storage Devices\n            </h2>\n\n            <div className=\"bg-desert-white rounded-lg p-8 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow\">\n              {storageItems.length > 0 ? (\n                <HorizontalBarChart\n                  items={storageItems}\n                  progressiveBarColor={true}\n                  statuses={[\n                    {\n                      label: 'Normal',\n                      min_threshold: 0,\n                      color_class: 'bg-desert-olive',\n                    },\n                    {\n                      label: 'Warning - Usage High',\n                      min_threshold: 75,\n                      color_class: 'bg-desert-orange',\n                    },\n                    {\n                      label: 'Critical - Disk Almost Full',\n                      min_threshold: 90,\n                      color_class: 'bg-desert-red',\n                    },\n                  ]}\n                />\n              ) : (\n                <div className=\"text-center text-desert-stone-dark py-8\">\n                  No storage devices detected\n                </div>\n              )}\n            </div>\n          </section>\n          <section>\n            <h2 className=\"text-2xl font-bold text-desert-green mb-6 flex items-center gap-2\">\n              <div className=\"w-1 h-6 bg-desert-green\" />\n              System Status\n            </h2>\n            <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4\">\n              <StatusCard title=\"System Uptime\" value={uptimeDisplay} />\n              <StatusCard title=\"CPU Cores\" value={info?.cpu.cores || 0} />\n              <StatusCard title=\"Storage Devices\" value={storageItems.length} />\n            </div>\n          </section>\n        </main>\n      </div>\n    </SettingsLayout>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/pages/settings/update.tsx",
    "content": "import { Head } from '@inertiajs/react'\nimport SettingsLayout from '~/layouts/SettingsLayout'\nimport StyledButton from '~/components/StyledButton'\nimport StyledTable from '~/components/StyledTable'\nimport StyledSectionHeader from '~/components/StyledSectionHeader'\nimport ActiveDownloads from '~/components/ActiveDownloads'\nimport Alert from '~/components/Alert'\nimport { useEffect, useState } from 'react'\nimport { IconAlertCircle, IconArrowBigUpLines, IconCheck, IconCircleCheck, IconReload } from '@tabler/icons-react'\nimport { SystemUpdateStatus } from '../../../types/system'\nimport type { ContentUpdateCheckResult, ResourceUpdateInfo } from '../../../types/collections'\nimport api from '~/lib/api'\nimport Input from '~/components/inputs/Input'\nimport Switch from '~/components/inputs/Switch'\nimport { useMutation } from '@tanstack/react-query'\nimport { useNotifications } from '~/context/NotificationContext'\nimport { useSystemSetting } from '~/hooks/useSystemSetting'\n\ntype Props = {\n  updateAvailable: boolean\n  latestVersion: string\n  currentVersion: string\n  earlyAccess: boolean\n}\n\nfunction ContentUpdatesSection() {\n  const { addNotification } = useNotifications()\n  const [checkResult, setCheckResult] = useState<ContentUpdateCheckResult | null>(null)\n  const [isChecking, setIsChecking] = useState(false)\n  const [applyingIds, setApplyingIds] = useState<Set<string>>(new Set())\n  const [isApplyingAll, setIsApplyingAll] = useState(false)\n\n  const handleCheck = async () => {\n    setIsChecking(true)\n    try {\n      const result = await api.checkForContentUpdates()\n      if (result) {\n        setCheckResult(result)\n      }\n    } catch {\n      setCheckResult({\n        updates: [],\n        checked_at: new Date().toISOString(),\n        error: 'Failed to check for content updates',\n      })\n    } finally {\n      setIsChecking(false)\n    }\n  }\n\n  const handleApply = async (update: ResourceUpdateInfo) => {\n    setApplyingIds((prev) => new Set(prev).add(update.resource_id))\n    try {\n      const result = await api.applyContentUpdate(update)\n      if (result?.success) {\n        addNotification({ type: 'success', message: `Update started for ${update.resource_id}` })\n        // Remove from the updates list\n        setCheckResult((prev) =>\n          prev\n            ? { ...prev, updates: prev.updates.filter((u) => u.resource_id !== update.resource_id) }\n            : prev\n        )\n      } else {\n        addNotification({ type: 'error', message: result?.error || 'Failed to start update' })\n      }\n    } catch {\n      addNotification({ type: 'error', message: `Failed to start update for ${update.resource_id}` })\n    } finally {\n      setApplyingIds((prev) => {\n        const next = new Set(prev)\n        next.delete(update.resource_id)\n        return next\n      })\n    }\n  }\n\n  const handleApplyAll = async () => {\n    if (!checkResult?.updates.length) return\n    setIsApplyingAll(true)\n    try {\n      const result = await api.applyAllContentUpdates(checkResult.updates)\n      if (result?.results) {\n        const succeeded = result.results.filter((r) => r.success).length\n        const failed = result.results.filter((r) => !r.success).length\n        if (succeeded > 0) {\n          addNotification({ type: 'success', message: `Started ${succeeded} update(s)` })\n        }\n        if (failed > 0) {\n          addNotification({ type: 'error', message: `${failed} update(s) could not be started` })\n        }\n        // Remove successful updates from the list\n        const successIds = new Set(result.results.filter((r) => r.success).map((r) => r.resource_id))\n        setCheckResult((prev) =>\n          prev\n            ? { ...prev, updates: prev.updates.filter((u) => !successIds.has(u.resource_id)) }\n            : prev\n        )\n      }\n    } catch {\n      addNotification({ type: 'error', message: 'Failed to apply updates' })\n    } finally {\n      setIsApplyingAll(false)\n    }\n  }\n\n  return (\n    <div className=\"mt-8\">\n      <StyledSectionHeader title=\"Content Updates\" />\n\n      <div className=\"bg-surface-primary rounded-lg border shadow-md overflow-hidden p-6\">\n        <div className=\"flex items-center justify-between\">\n          <p className=\"text-desert-stone-dark\">\n            Check if newer versions of your installed ZIM files and maps are available.\n          </p>\n          <StyledButton\n            variant=\"primary\"\n            icon=\"IconRefresh\"\n            onClick={handleCheck}\n            loading={isChecking}\n          >\n            Check for Content Updates\n          </StyledButton>\n        </div>\n\n        {checkResult?.error && (\n          <Alert\n            type=\"warning\"\n            title=\"Update Check Issue\"\n            message={checkResult.error}\n            variant=\"bordered\"\n            className=\"my-4\"\n          />\n        )}\n\n        {checkResult && !checkResult.error && checkResult.updates.length === 0 && (\n          <Alert\n            type=\"success\"\n            title=\"All Content Up to Date\"\n            message=\"All your installed content is running the latest available version.\"\n            variant=\"bordered\"\n            className=\"my-4\"\n          />\n        )}\n\n        {checkResult && checkResult.updates.length > 0 && (\n          <div className=\"mt-4\">\n            <div className=\"flex items-center justify-between mb-3\">\n              <p className=\"text-sm text-desert-stone-dark\">\n                {checkResult.updates.length} update(s) available\n              </p>\n              <StyledButton\n                variant=\"primary\"\n                size=\"sm\"\n                icon=\"IconDownload\"\n                onClick={handleApplyAll}\n                loading={isApplyingAll}\n              >\n                Update All ({checkResult.updates.length})\n              </StyledButton>\n            </div>\n            <StyledTable\n              data={checkResult.updates}\n              columns={[\n                {\n                  accessor: 'resource_id',\n                  title: 'Title',\n                  render: (record) => (\n                    <span className=\"font-medium text-desert-green\">{record.resource_id}</span>\n                  ),\n                },\n                {\n                  accessor: 'resource_type',\n                  title: 'Type',\n                  render: (record) => (\n                    <span\n                      className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${record.resource_type === 'zim'\n                        ? 'bg-blue-100 text-blue-800'\n                        : 'bg-emerald-100 text-emerald-800'\n                        }`}\n                    >\n                      {record.resource_type === 'zim' ? 'ZIM' : 'Map'}\n                    </span>\n                  ),\n                },\n                {\n                  accessor: 'installed_version',\n                  title: 'Version',\n                  render: (record) => (\n                    <span className=\"text-desert-stone-dark\">\n                      {record.installed_version} → {record.latest_version}\n                    </span>\n                  ),\n                },\n                {\n                  accessor: 'resource_id',\n                  title: '',\n                  render: (record) => (\n                    <StyledButton\n                      variant=\"secondary\"\n                      size=\"sm\"\n                      icon=\"IconDownload\"\n                      onClick={() => handleApply(record)}\n                      loading={applyingIds.has(record.resource_id)}\n                    >\n                      Update\n                    </StyledButton>\n                  ),\n                },\n              ]}\n            />\n          </div>\n        )}\n\n        {checkResult?.checked_at && (\n          <p className=\"text-xs text-desert-stone mt-3\">\n            Last checked: {new Date(checkResult.checked_at).toLocaleString()}\n          </p>\n        )}\n      </div>\n\n      <ActiveDownloads withHeader />\n    </div>\n  )\n}\n\nexport default function SystemUpdatePage(props: { system: Props }) {\n  const { addNotification } = useNotifications()\n\n  const [isUpdating, setIsUpdating] = useState(false)\n  const [updateStatus, setUpdateStatus] = useState<SystemUpdateStatus | null>(null)\n  const [error, setError] = useState<string | null>(null)\n  const [showLogs, setShowLogs] = useState(false)\n  const [logs, setLogs] = useState<string>('')\n  const [email, setEmail] = useState('')\n  const [versionInfo, setVersionInfo] = useState<Omit<Props, 'earlyAccess'>>(props.system)\n  const [showConnectionLostNotice, setShowConnectionLostNotice] = useState(false)\n\n  const earlyAccessSetting = useSystemSetting({\n    key: 'system.earlyAccess', initialData: {\n      key: 'system.earlyAccess',\n      value: props.system.earlyAccess,\n    }\n  })\n\n  useEffect(() => {\n    if (!isUpdating) return\n\n    const interval = setInterval(async () => {\n      try {\n        const response = await api.getSystemUpdateStatus()\n        if (!response) {\n          throw new Error('Failed to fetch update status')\n        }\n        setUpdateStatus(response)\n\n        // If we can connect again, hide the connection lost notice\n        setShowConnectionLostNotice(false)\n\n        // Check if update is complete or errored\n        if (response.stage === 'complete') {\n          // Re-check version so the KV store clears the stale \"update available\" flag\n          // before we reload, otherwise the banner shows \"current → current\"\n          try {\n            await api.checkLatestVersion(true)\n          } catch {\n            // Non-critical - page reload will still work\n          }\n          setTimeout(() => {\n            window.location.reload()\n          }, 2000)\n        } else if (response.stage === 'error') {\n          setIsUpdating(false)\n          setError(response.message)\n        }\n      } catch (err) {\n        // During container restart, we'll lose connection - this is expected\n        // Show a notice to inform the user that this is normal\n        setShowConnectionLostNotice(true)\n        // Continue polling to detect when the container comes back up\n        console.log('Polling update status (container may be restarting)...')\n      }\n    }, 2000)\n\n    return () => clearInterval(interval)\n  }, [isUpdating])\n\n  const handleStartUpdate = async () => {\n    try {\n      setError(null)\n      setIsUpdating(true)\n      const response = await api.startSystemUpdate()\n      if (!response || !response.success) {\n        throw new Error('Failed to start update')\n      }\n    } catch (err: any) {\n      setIsUpdating(false)\n      setError(err.response?.data?.error || err.message || 'Failed to start update')\n    }\n  }\n\n  const handleViewLogs = async () => {\n    try {\n      const response = await api.getSystemUpdateLogs()\n      if (!response) {\n        throw new Error('Failed to fetch update logs')\n      }\n      setLogs(response.logs)\n      setShowLogs(true)\n    } catch (err) {\n      setError('Failed to fetch update logs')\n    }\n  }\n\n  const checkVersionMutation = useMutation({\n    mutationKey: ['checkLatestVersion'],\n    mutationFn: () => api.checkLatestVersion(true),\n    onSuccess: (data) => {\n      if (data) {\n        setVersionInfo({\n          updateAvailable: data.updateAvailable,\n          latestVersion: data.latestVersion,\n          currentVersion: data.currentVersion,\n        })\n        if (data.updateAvailable) {\n          addNotification({\n            type: 'success',\n            message: `Update available: ${data.latestVersion}`,\n          })\n        } else {\n          addNotification({ type: 'success', message: 'System is up to date' })\n        }\n        setError(null)\n      }\n    },\n    onError: (error: any) => {\n      const errorMessage = error?.message || 'Failed to check for updates'\n      setError(errorMessage)\n      addNotification({ type: 'error', message: errorMessage })\n    },\n  })\n\n  const getProgressBarColor = () => {\n    if (updateStatus?.stage === 'error') return 'bg-desert-red'\n    if (updateStatus?.stage === 'complete') return 'bg-desert-olive'\n    return 'bg-desert-green'\n  }\n\n  const getStatusIcon = () => {\n    if (updateStatus?.stage === 'complete')\n      return <IconCheck className=\"h-12 w-12 text-desert-olive\" />\n    if (updateStatus?.stage === 'error')\n      return <IconAlertCircle className=\"h-12 w-12 text-desert-red\" />\n    if (isUpdating) return <IconReload className=\"h-12 w-12 text-desert-green animate-spin\" />\n    if (props.system.updateAvailable)\n      return <IconArrowBigUpLines className=\"h-16 w-16 text-desert-green\" />\n    return <IconCircleCheck className=\"h-16 w-16 text-desert-olive\" />\n  }\n\n  const updateSettingMutation = useMutation({\n    mutationFn: async ({ key, value }: { key: string; value: boolean }) => {\n      return await api.updateSetting(key, value)\n    },\n    onSuccess: () => {\n      addNotification({ message: 'Setting updated successfully.', type: 'success' })\n      earlyAccessSetting.refetch()\n    },\n    onError: (error) => {\n      console.error('Error updating setting:', error)\n      addNotification({ message: 'There was an error updating the setting. Please try again.', type: 'error' })\n    },\n  })\n\n  const subscribeToReleaseNotesMutation = useMutation({\n    mutationKey: ['subscribeToReleaseNotes'],\n    mutationFn: (email: string) => api.subscribeToReleaseNotes(email),\n    onSuccess: (data) => {\n      if (data && data.success) {\n        addNotification({ type: 'success', message: 'Successfully subscribed to release notes!' })\n        setEmail('')\n      } else {\n        addNotification({\n          type: 'error',\n          message: `Failed to subscribe: ${data?.message || 'Unknown error'}`,\n        })\n      }\n    },\n    onError: (error: any) => {\n      addNotification({\n        type: 'error',\n        message: `Error subscribing to release notes: ${error.message || 'Unknown error'}`,\n      })\n    },\n  })\n\n  return (\n    <SettingsLayout>\n      <Head title=\"System Update\" />\n      <div className=\"xl:pl-72 w-full\">\n        <main className=\"px-6 lg:px-12 py-6 lg:py-8\">\n          <div className=\"mb-8\">\n            <h1 className=\"text-4xl font-bold text-desert-green mb-2\">System Update</h1>\n            <p className=\"text-desert-stone-dark\">\n              Keep your Project N.O.M.A.D. instance up to date with the latest features and\n              improvements.\n            </p>\n          </div>\n\n          {error && (\n            <div className=\"mb-6\">\n              <Alert\n                type=\"error\"\n                title=\"Update Failed\"\n                message={error}\n                variant=\"bordered\"\n                dismissible\n                onDismiss={() => setError(null)}\n              />\n            </div>\n          )}\n          {isUpdating && updateStatus?.stage === 'recreating' && (\n            <div className=\"mb-6\">\n              <Alert\n                type=\"info\"\n                title=\"Container Restarting\"\n                message=\"The admin container is restarting. This page will reload automatically when the update is complete.\"\n                variant=\"solid\"\n              />\n            </div>\n          )}\n          {isUpdating && showConnectionLostNotice && (\n            <div className=\"mb-6\">\n              <Alert\n                type=\"info\"\n                title=\"Connection Temporarily Lost (Expected)\"\n                message=\"You may see error notifications while the backend restarts during the update. This is completely normal and expected. Connection should be restored momentarily.\"\n                variant=\"solid\"\n              />\n            </div>\n          )}\n          <div className=\"bg-surface-primary rounded-lg border shadow-md overflow-hidden\">\n            <div className=\"p-8 text-center\">\n              <div className=\"flex justify-center mb-4\">{getStatusIcon()}</div>\n\n              {!isUpdating && (\n                <>\n                  <h2 className=\"text-2xl font-bold text-desert-green mb-2\">\n                    {props.system.updateAvailable ? 'Update Available' : 'System Up to Date'}\n                  </h2>\n                  <p className=\"text-desert-stone-dark mb-6\">\n                    {props.system.updateAvailable\n                      ? `A new version (${props.system.latestVersion}) is available for your Project N.O.M.A.D. instance.`\n                      : 'Your system is running the latest version!'}\n                  </p>\n                </>\n              )}\n\n              {isUpdating && updateStatus && (\n                <>\n                  <h2 className=\"text-2xl font-bold text-desert-green mb-2 capitalize\">\n                    {updateStatus.stage === 'idle' ? 'Preparing Update' : updateStatus.stage}\n                  </h2>\n                  <p className=\"text-desert-stone-dark mb-6\">{updateStatus.message}</p>\n                </>\n              )}\n\n              <div className=\"flex justify-center gap-8 mb-6\">\n                <div className=\"text-center\">\n                  <p className=\"text-sm text-desert-stone mb-1\">Current Version</p>\n                  <p className=\"text-xl font-bold text-desert-green\">\n                    {versionInfo.currentVersion}\n                  </p>\n                </div>\n                {versionInfo.updateAvailable && (\n                  <>\n                    <div className=\"flex items-center\">\n                      <svg\n                        className=\"h-6 w-6 text-desert-stone\"\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        viewBox=\"0 0 24 24\"\n                      >\n                        <path\n                          strokeLinecap=\"round\"\n                          strokeLinejoin=\"round\"\n                          strokeWidth={2}\n                          d=\"M13 7l5 5m0 0l-5 5m5-5H6\"\n                        />\n                      </svg>\n                    </div>\n                    <div className=\"text-center\">\n                      <p className=\"text-sm text-desert-stone mb-1\">Latest Version</p>\n                      <p className=\"text-xl font-bold text-desert-olive\">\n                        {versionInfo.latestVersion}\n                      </p>\n                    </div>\n                  </>\n                )}\n              </div>\n              {isUpdating && updateStatus && (\n                <div className=\"mb-4\">\n                  <div className=\"w-full bg-desert-stone-light rounded-full h-3 overflow-hidden\">\n                    <div\n                      className={`${getProgressBarColor()} h-full transition-all duration-500 ease-out`}\n                      style={{ width: `${updateStatus.progress}%` }}\n                    />\n                  </div>\n                  <p className=\"text-sm text-desert-stone mt-2\">\n                    {updateStatus.progress}% complete\n                  </p>\n                </div>\n              )}\n              {!isUpdating && (\n                <div className=\"flex justify-center gap-4\">\n                  <StyledButton\n                    variant=\"primary\"\n                    size=\"lg\"\n                    icon=\"IconDownload\"\n                    onClick={handleStartUpdate}\n                    disabled={!versionInfo.updateAvailable}\n                  >\n                    {versionInfo.updateAvailable ? 'Start Update' : 'No Update Available'}\n                  </StyledButton>\n                  <StyledButton\n                    variant=\"ghost\"\n                    size=\"lg\"\n                    icon=\"IconRefresh\"\n                    onClick={() => checkVersionMutation.mutate()}\n                    loading={checkVersionMutation.isPending}\n                  >\n                    Check Again\n                  </StyledButton>\n                </div>\n              )}\n            </div>\n            <div className=\"border-t bg-surface-primary p-6\">\n              <h3 className=\"text-lg font-semibold text-desert-green mb-4\">\n                What happens during an update?\n              </h3>\n              <div className=\"space-y-3\">\n                <div className=\"flex items-start gap-3\">\n                  <div className=\"flex-shrink-0 w-6 h-6 rounded-full bg-desert-green text-white flex items-center justify-center text-sm font-bold\">\n                    1\n                  </div>\n                  <div>\n                    <p className=\"font-medium text-desert-stone-dark\">Pull Latest Images</p>\n                    <p className=\"text-sm text-desert-stone\">\n                      Downloads the newest Docker images for all core containers\n                    </p>\n                  </div>\n                </div>\n                <div className=\"flex items-start gap-3\">\n                  <div className=\"flex-shrink-0 w-6 h-6 rounded-full bg-desert-green text-white flex items-center justify-center text-sm font-bold\">\n                    2\n                  </div>\n                  <div>\n                    <p className=\"font-medium text-desert-stone-dark\">Recreate Containers</p>\n                    <p className=\"text-sm text-desert-stone\">\n                      Safely stops and recreates all core containers with the new images\n                    </p>\n                  </div>\n                </div>\n                <div className=\"flex items-start gap-3\">\n                  <div className=\"flex-shrink-0 w-6 h-6 rounded-full bg-desert-green text-white flex items-center justify-center text-sm font-bold\">\n                    3\n                  </div>\n                  <div>\n                    <p className=\"font-medium text-desert-stone-dark\">Automatic Reload</p>\n                    <p className=\"text-sm text-desert-stone\">\n                      This page will automatically reload when the update is complete\n                    </p>\n                  </div>\n                </div>\n              </div>\n\n              {isUpdating && (\n                <div className=\"mt-6 pt-6 border-t border-desert-stone-light\">\n                  <StyledButton\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    icon=\"IconLogs\"\n                    onClick={handleViewLogs}\n                    fullWidth\n                  >\n                    View Update Logs\n                  </StyledButton>\n                </div>\n              )}\n            </div>\n          </div>\n          <div className=\"mt-6 grid grid-cols-1 md:grid-cols-2 gap-4\">\n            <Alert\n              type=\"info\"\n              title=\"Backup Reminder\"\n              message=\"While updates are designed to be safe, it's always recommended to backup any critical data before proceeding.\"\n              variant=\"solid\"\n            />\n            <Alert\n              type=\"warning\"\n              title=\"Temporary Downtime\"\n              message=\"Services will be briefly unavailable during the update process. This typically takes 2-5 minutes depending on your internet connection.\"\n              variant=\"solid\"\n            />\n          </div>\n          <StyledSectionHeader title=\"Early Access\" className=\"mt-8\" />\n          <div className=\"bg-surface-primary rounded-lg border shadow-md overflow-hidden mt-6 p-6\">\n            <Switch\n              checked={earlyAccessSetting.data?.value || false}\n              onChange={(newVal) => {\n                updateSettingMutation.mutate({ key: 'system.earlyAccess', value: newVal })\n              }}\n              disabled={updateSettingMutation.isPending}\n              label=\"Enable Early Access\"\n              description=\"Receive release candidate (RC) versions before they are officially released. Note: RC versions may contain bugs and are not recommended for environments where stability and data integrity are critical.\"\n            />\n          </div>\n          <ContentUpdatesSection />\n          <div className=\"bg-surface-primary rounded-lg border shadow-md overflow-hidden py-6 mt-12\">\n            <div className=\"flex flex-col md:flex-row justify-between items-center p-8 gap-y-8 md:gap-y-0 gap-x-8\">\n              <div>\n                <h2 className=\"max-w-xl text-lg font-bold text-desert-green sm:text-xl lg:col-span-7\">\n                  Want to stay updated with the latest from Project N.O.M.A.D.? Subscribe to receive\n                  release notes directly to your inbox. Unsubscribe anytime.\n                </h2>\n              </div>\n              <div className=\"flex flex-col\">\n                <div className=\"flex gap-x-3\">\n                  <Input\n                    name=\"email\"\n                    label=\"\"\n                    type=\"email\"\n                    placeholder=\"Your email address\"\n                    disabled={false}\n                    value={email}\n                    onChange={(e) => setEmail(e.target.value)}\n                    className=\"w-full\"\n                    containerClassName=\"!mt-0\"\n                  />\n                  <StyledButton\n                    variant=\"primary\"\n                    disabled={!email}\n                    onClick={() => subscribeToReleaseNotesMutation.mutateAsync(email)}\n                    loading={subscribeToReleaseNotesMutation.isPending}\n                  >\n                    Subscribe\n                  </StyledButton>\n                </div>\n                <p className=\"mt-2 text-sm text-desert-stone-dark\">\n                  We care about your privacy. Project N.O.M.A.D. will never share your email with\n                  third parties or send you spam.\n                </p>\n              </div>\n            </div>\n          </div>\n\n          {showLogs && (\n            <div className=\"fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50\">\n              <div className=\"bg-surface-primary rounded-lg shadow-2xl max-w-4xl w-full max-h-[80vh] flex flex-col\">\n                <div className=\"p-6 border-b border-desert-stone-light flex justify-between items-center\">\n                  <h3 className=\"text-xl font-bold text-desert-green\">Update Logs</h3>\n                  <button\n                    onClick={() => setShowLogs(false)}\n                    className=\"text-desert-stone hover:text-desert-green transition-colors\"\n                  >\n                    <svg className=\"h-6 w-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                      <path\n                        strokeLinecap=\"round\"\n                        strokeLinejoin=\"round\"\n                        strokeWidth={2}\n                        d=\"M6 18L18 6M6 6l12 12\"\n                      />\n                    </svg>\n                  </button>\n                </div>\n                <div className=\"p-6 overflow-auto flex-1\">\n                  <pre className=\"bg-black text-green-400 p-4 rounded text-xs font-mono whitespace-pre-wrap\">\n                    {logs || 'No logs available yet...'}\n                  </pre>\n                </div>\n                <div className=\"p-6 border-t border-desert-stone-light\">\n                  <StyledButton variant=\"secondary\" onClick={() => setShowLogs(false)} fullWidth>\n                    Close\n                  </StyledButton>\n                </div>\n              </div>\n            </div>\n          )}\n        </main>\n      </div >\n    </SettingsLayout >\n  )\n}\n"
  },
  {
    "path": "admin/inertia/pages/settings/zim/index.tsx",
    "content": "import { Head } from '@inertiajs/react'\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\nimport StyledTable from '~/components/StyledTable'\nimport SettingsLayout from '~/layouts/SettingsLayout'\nimport api from '~/lib/api'\nimport StyledButton from '~/components/StyledButton'\nimport { useModals } from '~/context/ModalContext'\nimport StyledModal from '~/components/StyledModal'\nimport useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'\nimport Alert from '~/components/Alert'\nimport { ZimFileWithMetadata } from '../../../../types/zim'\nimport { SERVICE_NAMES } from '../../../../constants/service_names'\n\nexport default function ZimPage() {\n  const queryClient = useQueryClient()\n  const { openModal, closeAllModals } = useModals()\n  const { isInstalled } = useServiceInstalledStatus(SERVICE_NAMES.KIWIX)\n  const { data, isLoading } = useQuery<ZimFileWithMetadata[]>({\n    queryKey: ['zim-files'],\n    queryFn: getFiles,\n  })\n\n  async function getFiles() {\n    const res = await api.listZimFiles()\n    return res.data.files\n  }\n\n  async function confirmDeleteFile(file: ZimFileWithMetadata) {\n    openModal(\n      <StyledModal\n        title=\"Confirm Delete?\"\n        onConfirm={() => {\n          deleteFileMutation.mutateAsync(file)\n          closeAllModals()\n        }}\n        onCancel={closeAllModals}\n        open={true}\n        confirmText=\"Delete\"\n        cancelText=\"Cancel\"\n        confirmVariant=\"danger\"\n      >\n        <p className=\"text-text-secondary\">\n          Are you sure you want to delete {file.name}? This action cannot be undone.\n        </p>\n      </StyledModal>,\n      'confirm-delete-file-modal'\n    )\n  }\n\n  const deleteFileMutation = useMutation({\n    mutationFn: async (file: ZimFileWithMetadata) => api.deleteZimFile(file.name.replace('.zim', '')),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['zim-files'] })\n    },\n  })\n\n  return (\n    <SettingsLayout>\n      <Head title=\"Content Manager | Project N.O.M.A.D.\" />\n      <div className=\"xl:pl-72 w-full\">\n        <main className=\"px-12 py-6\">\n          <div className=\"flex items-center justify-between\">\n            <div className=\"flex flex-col\">\n              <h1 className=\"text-4xl font-semibold mb-2\">Content Manager</h1>\n              <p className=\"text-text-muted\">\n                Manage your stored content files.\n              </p>\n            </div>\n          </div>\n          {!isInstalled && (\n            <Alert\n              title=\"The Kiwix application is not installed. Please install it to view downloaded ZIM files\"\n              type=\"warning\"\n              variant='solid'\n              className=\"!mt-6\"\n            />\n          )}\n          <StyledTable<ZimFileWithMetadata & { actions?: any }>\n            className=\"font-semibold mt-4\"\n            rowLines={true}\n            loading={isLoading}\n            compact\n            columns={[\n              {\n                accessor: 'title',\n                title: 'Title',\n                render: (record) => (\n                  <span className=\"font-medium\">\n                    {record.title || record.name}\n                  </span>\n                ),\n              },\n              {\n                accessor: 'summary',\n                title: 'Summary',\n                render: (record) => (\n                  <span className=\"text-text-secondary text-sm line-clamp-2\">\n                    {record.summary || '—'}\n                  </span>\n                ),\n              },\n              {\n                accessor: 'actions',\n                title: 'Actions',\n                render: (record) => (\n                  <div className=\"flex space-x-2\">\n                    <StyledButton\n                      variant=\"danger\"\n                      icon={'IconTrash'}\n                      onClick={() => {\n                        confirmDeleteFile(record)\n                      }}\n                    >\n                      Delete\n                    </StyledButton>\n                  </div>\n                ),\n              },\n            ]}\n            data={data || []}\n          />\n        </main>\n      </div>\n    </SettingsLayout>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/pages/settings/zim/remote-explorer.tsx",
    "content": "import {\n  keepPreviousData,\n  useInfiniteQuery,\n  useMutation,\n  useQuery,\n  useQueryClient,\n} from '@tanstack/react-query'\nimport api from '~/lib/api'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { useVirtualizer } from '@tanstack/react-virtual'\nimport StyledTable from '~/components/StyledTable'\nimport SettingsLayout from '~/layouts/SettingsLayout'\nimport { Head } from '@inertiajs/react'\nimport { ListRemoteZimFilesResponse, RemoteZimFileEntry } from '../../../../types/zim'\nimport { formatBytes } from '~/lib/util'\nimport StyledButton from '~/components/StyledButton'\nimport { useModals } from '~/context/ModalContext'\nimport StyledModal from '~/components/StyledModal'\nimport { useNotifications } from '~/context/NotificationContext'\nimport useInternetStatus from '~/hooks/useInternetStatus'\nimport Alert from '~/components/Alert'\nimport useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'\nimport Input from '~/components/inputs/Input'\nimport { IconSearch, IconBooks } from '@tabler/icons-react'\nimport useDebounce from '~/hooks/useDebounce'\nimport CategoryCard from '~/components/CategoryCard'\nimport TierSelectionModal from '~/components/TierSelectionModal'\nimport WikipediaSelector from '~/components/WikipediaSelector'\nimport StyledSectionHeader from '~/components/StyledSectionHeader'\nimport type { CategoryWithStatus, SpecTier } from '../../../../types/collections'\nimport useDownloads from '~/hooks/useDownloads'\nimport ActiveDownloads from '~/components/ActiveDownloads'\nimport { SERVICE_NAMES } from '../../../../constants/service_names'\n\nconst CURATED_CATEGORIES_KEY = 'curated-categories'\nconst WIKIPEDIA_STATE_KEY = 'wikipedia-state'\n\nexport default function ZimRemoteExplorer() {\n  const queryClient = useQueryClient()\n  const tableParentRef = useRef<HTMLDivElement>(null)\n\n  const { openModal, closeAllModals } = useModals()\n  const { addNotification } = useNotifications()\n  const { isOnline } = useInternetStatus()\n  const { isInstalled } = useServiceInstalledStatus(SERVICE_NAMES.KIWIX)\n  const { debounce } = useDebounce()\n\n  const [query, setQuery] = useState('')\n  const [queryUI, setQueryUI] = useState('')\n\n  // Category/tier selection state\n  const [tierModalOpen, setTierModalOpen] = useState(false)\n  const [activeCategory, setActiveCategory] = useState<CategoryWithStatus | null>(null)\n\n  // Wikipedia selection state\n  const [selectedWikipedia, setSelectedWikipedia] = useState<string | null>(null)\n  const [isSubmittingWikipedia, setIsSubmittingWikipedia] = useState(false)\n\n  const debouncedSetQuery = debounce((val: string) => {\n    setQuery(val)\n  }, 400)\n\n  // Fetch curated categories with tiers\n  const { data: categories } = useQuery({\n    queryKey: [CURATED_CATEGORIES_KEY],\n    queryFn: () => api.listCuratedCategories(),\n    refetchOnWindowFocus: false,\n  })\n\n  // Fetch Wikipedia options and state\n  const { data: wikipediaState, isLoading: isLoadingWikipedia } = useQuery({\n    queryKey: [WIKIPEDIA_STATE_KEY],\n    queryFn: () => api.getWikipediaState(),\n    refetchOnWindowFocus: false,\n  })\n\n  const { data: downloads, invalidate: invalidateDownloads } = useDownloads({\n    filetype: 'zim',\n    enabled: true,\n  })\n\n  const { data, fetchNextPage, isFetching, isLoading } =\n    useInfiniteQuery<ListRemoteZimFilesResponse>({\n      queryKey: ['remote-zim-files', query],\n      queryFn: async ({ pageParam = 0 }) => {\n        const pageParsed = parseInt((pageParam as number).toString(), 10)\n        const start = isNaN(pageParsed) ? 0 : pageParsed * 12\n        const res = await api.listRemoteZimFiles({ start, count: 12, query: query || undefined })\n        if (!res) {\n          throw new Error('Failed to fetch remote ZIM files.')\n        }\n        return res.data\n      },\n      initialPageParam: 0,\n      getNextPageParam: (_lastPage, pages) => {\n        if (!_lastPage.has_more) {\n          return undefined // No more pages to fetch\n        }\n        return pages.length\n      },\n      refetchOnWindowFocus: false,\n      placeholderData: keepPreviousData,\n    })\n\n  const flatData = useMemo(() => {\n    const mapped = data?.pages.flatMap((page) => page.items) || []\n    // remove items that are currently downloading\n    return mapped.filter((item) => {\n      const isDownloading = downloads?.some((download) => {\n        const filename = item.download_url.split('/').pop()\n        return filename && download.filepath.endsWith(filename)\n      })\n      return !isDownloading\n    })\n  }, [data, downloads])\n  const hasMore = useMemo(() => data?.pages[data.pages.length - 1]?.has_more || false, [data])\n\n  const fetchOnBottomReached = useCallback(\n    (parentRef?: HTMLDivElement | null) => {\n      if (parentRef) {\n        const { scrollHeight, scrollTop, clientHeight } = parentRef\n        //once the user has scrolled within 200px of the bottom of the table, fetch more data if we can\n        if (\n          scrollHeight - scrollTop - clientHeight < 200 &&\n          !isFetching &&\n          hasMore &&\n          flatData.length > 0\n        ) {\n          fetchNextPage()\n        }\n      }\n    },\n    [fetchNextPage, isFetching, hasMore, flatData.length]\n  )\n\n  const virtualizer = useVirtualizer({\n    count: flatData.length,\n    estimateSize: () => 48, // Estimate row height\n    getScrollElement: () => tableParentRef.current,\n    overscan: 5, // Number of items to render outside the visible area\n  })\n\n  //a check on mount and after a fetch to see if the table is already scrolled to the bottom and immediately needs to fetch more data\n  useEffect(() => {\n    fetchOnBottomReached(tableParentRef.current)\n  }, [fetchOnBottomReached])\n\n  async function confirmDownload(record: RemoteZimFileEntry) {\n    openModal(\n      <StyledModal\n        title=\"Confirm Download?\"\n        onConfirm={() => {\n          downloadFile(record)\n          closeAllModals()\n        }}\n        onCancel={closeAllModals}\n        open={true}\n        confirmText=\"Download\"\n        cancelText=\"Cancel\"\n        confirmVariant=\"primary\"\n      >\n        <p className=\"text-text-primary\">\n          Are you sure you want to download{' '}\n          <strong>{record.title}</strong>? It may take some time for it\n          to be available depending on the file size and your internet connection. The Kiwix\n          application will be restarted after the download is complete.\n        </p>\n      </StyledModal>,\n      'confirm-download-file-modal'\n    )\n  }\n\n  async function downloadFile(record: RemoteZimFileEntry) {\n    try {\n      await api.downloadRemoteZimFile(record.download_url, {\n        title: record.title,\n        summary: record.summary,\n        author: record.author,\n        size_bytes: record.size_bytes,\n      })\n      invalidateDownloads()\n    } catch (error) {\n      console.error('Error downloading file:', error)\n    }\n  }\n\n  // Category/tier handlers\n  const handleCategoryClick = (category: CategoryWithStatus) => {\n    if (!isOnline) return\n    setActiveCategory(category)\n    setTierModalOpen(true)\n  }\n\n  const handleTierSelect = async (category: CategoryWithStatus, tier: SpecTier) => {\n    try {\n      await api.downloadCategoryTier(category.slug, tier.slug)\n\n      addNotification({\n        message: `Started downloading \"${category.name} - ${tier.name}\"`,\n        type: 'success',\n      })\n      invalidateDownloads()\n\n      // Refresh categories to update the installed tier display\n      queryClient.invalidateQueries({ queryKey: [CURATED_CATEGORIES_KEY] })\n    } catch (error) {\n      console.error('Error downloading tier resources:', error)\n      addNotification({\n        message: 'An error occurred while starting downloads.',\n        type: 'error',\n      })\n    }\n  }\n\n  const closeTierModal = () => {\n    setTierModalOpen(false)\n    setActiveCategory(null)\n  }\n\n  // Wikipedia selection handlers\n  const handleWikipediaSelect = (optionId: string) => {\n    if (!isOnline) return\n    setSelectedWikipedia(optionId)\n  }\n\n  const handleWikipediaSubmit = async () => {\n    if (!selectedWikipedia) return\n\n    setIsSubmittingWikipedia(true)\n    try {\n      const result = await api.selectWikipedia(selectedWikipedia)\n      if (result?.success) {\n        addNotification({\n          message:\n            selectedWikipedia === 'none'\n              ? 'Wikipedia removed successfully'\n              : 'Wikipedia download started',\n          type: 'success',\n        })\n        invalidateDownloads()\n        queryClient.invalidateQueries({ queryKey: [WIKIPEDIA_STATE_KEY] })\n        setSelectedWikipedia(null)\n      } else {\n        addNotification({\n          message: result?.message || 'Failed to change Wikipedia selection',\n          type: 'error',\n        })\n      }\n    } catch (error) {\n      console.error('Error selecting Wikipedia:', error)\n      addNotification({\n        message: 'An error occurred while changing Wikipedia selection',\n        type: 'error',\n      })\n    } finally {\n      setIsSubmittingWikipedia(false)\n    }\n  }\n\n  const refreshManifests = useMutation({\n    mutationFn: () => api.refreshManifests(),\n    onSuccess: () => {\n      addNotification({\n        message: 'Successfully refreshed content collections.',\n        type: 'success',\n      })\n      queryClient.invalidateQueries({ queryKey: [CURATED_CATEGORIES_KEY] })\n      queryClient.invalidateQueries({ queryKey: [WIKIPEDIA_STATE_KEY] })\n    },\n  })\n\n  return (\n    <SettingsLayout>\n      <Head title=\"Content Explorer | Project N.O.M.A.D.\" />\n      <div className=\"xl:pl-72 w-full\">\n        <main className=\"px-12 py-6\">\n          <div className=\"flex justify-between items-center\">\n            <div className=\"flex flex-col\">\n              <h1 className=\"text-4xl font-semibold mb-2\">Content Explorer</h1>\n              <p className=\"text-text-muted\">Browse and download content for offline reading!</p>\n            </div>\n          </div>\n          {!isOnline && (\n            <Alert\n              title=\"No internet connection. You may not be able to download files.\"\n              message=\"\"\n              type=\"warning\"\n              variant=\"solid\"\n              className=\"!mt-6\"\n            />\n          )}\n          {!isInstalled && (\n            <Alert\n              title=\"The Kiwix application is not installed. Please install it to view downloaded content files.\"\n              type=\"warning\"\n              variant=\"solid\"\n              className=\"!mt-6\"\n            />\n          )}\n          <div className=\"mt-8 mb-6 flex items-center justify-between\">\n            <StyledSectionHeader title=\"Curated Content\" className=\"!mb-0\" />\n            <StyledButton\n              onClick={() => refreshManifests.mutate()}\n              disabled={refreshManifests.isPending || !isOnline}\n              icon=\"IconRefresh\"\n            >\n              Force Refresh Collections\n            </StyledButton>\n          </div>\n          \n          {/* Wikipedia Selector */}\n          {isLoadingWikipedia ? (\n            <div className=\"mt-8 bg-surface-primary rounded-lg border border-border-subtle p-6\">\n              <div className=\"flex justify-center py-6\">\n                <div className=\"animate-spin rounded-full h-8 w-8 border-b-2 border-desert-green\"></div>\n              </div>\n            </div>\n          ) : wikipediaState && wikipediaState.options.length > 0 ? (\n            <div className=\"mt-8 bg-surface-primary rounded-lg border border-border-subtle p-6\">\n              <WikipediaSelector\n                options={wikipediaState.options}\n                currentSelection={wikipediaState.currentSelection}\n                selectedOptionId={selectedWikipedia}\n                onSelect={handleWikipediaSelect}\n                disabled={!isOnline}\n                showSubmitButton\n                onSubmit={handleWikipediaSubmit}\n                isSubmitting={isSubmittingWikipedia}\n              />\n            </div>\n          ) : null}\n\n          {/* Tiered Category Collections */}\n          <div className=\"flex items-center gap-3 mt-8 mb-4\">\n            <div className=\"w-10 h-10 rounded-full bg-surface-primary border border-border-subtle flex items-center justify-center shadow-sm\">\n              <IconBooks className=\"w-6 h-6 text-text-primary\" />\n            </div>\n            <div>\n              <h3 className=\"text-xl font-semibold text-text-primary\">Additional Content</h3>\n              <p className=\"text-sm text-text-muted\">Curated collections for offline reference</p>\n            </div>\n          </div>\n          {categories && categories.length > 0 ? (\n            <>\n              <div className=\"mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6\">\n                {categories.map((category) => (\n                  <CategoryCard\n                    key={category.slug}\n                    category={category}\n                    selectedTier={null}\n                    onClick={handleCategoryClick}\n                  />\n                ))}\n              </div>\n\n              {/* Tier Selection Modal */}\n              <TierSelectionModal\n                isOpen={tierModalOpen}\n                onClose={closeTierModal}\n                category={activeCategory}\n                selectedTierSlug={activeCategory?.installedTierSlug}\n                onSelectTier={handleTierSelect}\n              />\n            </>\n          ) : (\n            <p className=\"text-text-muted mt-4\">No curated content categories available.</p>\n          )}\n          <StyledSectionHeader title=\"Browse the Kiwix Library\" className=\"mt-12 mb-4\" />\n          <div className=\"flex justify-start mt-4\">\n            <Input\n              name=\"search\"\n              label=\"\"\n              placeholder=\"Search available ZIM files...\"\n              value={queryUI}\n              onChange={(e) => {\n                setQueryUI(e.target.value)\n                debouncedSetQuery(e.target.value)\n              }}\n              className=\"w-1/3\"\n              leftIcon={<IconSearch className=\"w-5 h-5 text-text-muted\" />}\n            />\n          </div>\n          <StyledTable<RemoteZimFileEntry & { actions?: any }>\n            data={flatData.map((i, idx) => {\n              const row = virtualizer.getVirtualItems().find((v) => v.index === idx)\n              return {\n                ...i,\n                height: `${row?.size || 48}px`, // Use the size from the virtualizer\n                translateY: row?.start || 0,\n              }\n            })}\n            ref={tableParentRef}\n            loading={isLoading}\n            columns={[\n              {\n                accessor: 'title',\n              },\n              {\n                accessor: 'author',\n              },\n              {\n                accessor: 'summary',\n              },\n              {\n                accessor: 'updated',\n                render(record) {\n                  return new Intl.DateTimeFormat('en-US', {\n                    dateStyle: 'medium',\n                  }).format(new Date(record.updated))\n                },\n              },\n              {\n                accessor: 'size_bytes',\n                title: 'Size',\n                render(record) {\n                  return formatBytes(record.size_bytes)\n                },\n              },\n              {\n                accessor: 'actions',\n                render(record) {\n                  return (\n                    <div className=\"flex space-x-2\">\n                      <StyledButton\n                        icon={'IconDownload'}\n                        onClick={() => {\n                          confirmDownload(record)\n                        }}\n                      >\n                        Download\n                      </StyledButton>\n                    </div>\n                  )\n                },\n              },\n            ]}\n            className=\"relative overflow-x-auto overflow-y-auto h-[600px] w-full mt-4\"\n            tableBodyStyle={{\n              position: 'relative',\n              height: `${virtualizer.getTotalSize()}px`,\n            }}\n            containerProps={{\n              onScroll: (e) => fetchOnBottomReached(e.currentTarget as HTMLDivElement),\n            }}\n            compact\n            rowLines\n          />\n          <ActiveDownloads filetype=\"zim\" withHeader />\n        </main>\n      </div>\n    </SettingsLayout>\n  )\n}\n"
  },
  {
    "path": "admin/inertia/providers/ModalProvider.tsx",
    "content": "import { useState, Fragment } from 'react'\nimport { ModalContext } from '~/context/ModalContext'\n\nexport interface ModalsProviderProps {\n  children: React.ReactNode\n}\n\nconst ModalsProvider: React.FC<ModalsProviderProps> = ({ children }) => {\n  const [modals, setModals] = useState<Record<string, React.ReactNode>>({})\n  const [, setPreventCloseOnOverlayClick] = useState<boolean>(false)\n\n  const openModal = (content: React.ReactNode, id: string, preventClose = false) => {\n    setModals((prev) => ({\n      ...prev,\n      [id]: content,\n    }))\n    setPreventCloseOnOverlayClick(preventClose)\n  }\n\n  const closeModal = (id: string) => {\n    setModals((prev) => {\n      const newModals = { ...prev }\n      delete newModals[id]\n      return newModals\n    })\n    if (Object.keys(modals).length === 1) {\n      setPreventCloseOnOverlayClick(false) // reset if last modal is closed\n    }\n  }\n\n  const closeAllModals = () => {\n    setModals({})\n    setPreventCloseOnOverlayClick(false) // reset\n  }\n\n  const _getCurrentModals = () => {\n    return modals\n  }\n\n  return (\n    <ModalContext.Provider value={{ openModal, closeModal, closeAllModals, _getCurrentModals }}>\n      {children}\n      {Object.keys(modals).length === 0 ? null : (\n        <>\n          {Object.entries(modals).map(([key, node]) => (\n            <Fragment key={key}>{node}</Fragment>\n          ))}\n        </>\n      )}\n    </ModalContext.Provider>\n  )\n}\n\nexport default ModalsProvider\n"
  },
  {
    "path": "admin/inertia/providers/NotificationProvider.tsx",
    "content": "import { useState, useEffect } from 'react'\nimport { NotificationContext, Notification } from '../context/NotificationContext'\nimport { IconExclamationCircle, IconCircleCheck, IconInfoCircle } from '@tabler/icons-react'\nimport { setGlobalNotificationCallback } from '~/lib/util'\n\nconst NotificationsProvider = ({ children }: { children: React.ReactNode }) => {\n  const [notifications, setNotifications] = useState<(Notification & { id: string })[]>([])\n\n  const addNotification = (newNotif: Notification) => {\n    const { message, type, duration = 5000 } = newNotif\n    const id = crypto.randomUUID()\n    setNotifications((prev) => [...prev, { id, message, type, duration }])\n\n    if (duration > 0) {\n      setTimeout(() => {\n        removeNotification(id)\n      }, duration)\n    }\n  }\n\n  // Set the global notification callback when provider mounts\n  useEffect(() => {\n    setGlobalNotificationCallback(addNotification)\n    return () => {\n      setGlobalNotificationCallback(() => {})\n    }\n  }, [])\n\n  const removeNotification = (id: string) => {\n    setNotifications((prev) => prev.filter((n) => n.id !== id))\n  }\n\n  const removeAllNotifications = () => {\n    setNotifications([])\n  }\n\n  const Icon = ({ type }: { type: string }) => {\n    switch (type) {\n      case 'error':\n        return <IconExclamationCircle className=\"h-5 w-5 text-red-500\" />\n      case 'success':\n        return <IconCircleCheck className=\"h-5 w-5 text-green-500\" />\n      case 'info':\n        return <IconInfoCircle className=\"h-5 w-5 text-blue-500\" />\n      default:\n        return <IconInfoCircle className=\"h-5 w-5 text-blue-500\" />\n    }\n  }\n\n  return (\n    <NotificationContext.Provider\n      value={{\n        notifications,\n        addNotification,\n        removeNotification,\n        removeAllNotifications,\n      }}\n    >\n      {children}\n      <div className=\"!fixed bottom-16 right-0 p-4 z-[9999]\">\n        {notifications.map((notification) => (\n          <div\n            key={notification.id}\n            className={`mb-4 p-4 rounded shadow-md border border-border-default bg-surface-primary max-w-96`}\n            onClick={() => removeNotification(notification.id)}\n          >\n            <div className=\"flex flex-row items-start gap-3\">\n              <div className=\"flex-shrink-0 mt-0.5\">\n                <Icon type={notification.type} />\n              </div>\n              <div className=\"flex-1 min-w-0\">\n                <p className=\"break-words\">{notification.message}</p>\n              </div>\n            </div>\n          </div>\n        ))}\n      </div>\n    </NotificationContext.Provider>\n  )\n}\n\nexport default NotificationsProvider\n"
  },
  {
    "path": "admin/inertia/providers/ThemeProvider.tsx",
    "content": "import { createContext, useContext } from 'react'\nimport { useTheme, Theme } from '~/hooks/useTheme'\n\ninterface ThemeContextType {\n  theme: Theme\n  setTheme: (theme: Theme) => void\n  toggleTheme: () => void\n}\n\nconst ThemeContext = createContext<ThemeContextType>({\n  theme: 'light',\n  setTheme: () => {},\n  toggleTheme: () => {},\n})\n\nexport function ThemeProvider({ children }: { children: React.ReactNode }) {\n  const themeState = useTheme()\n  return (\n    <ThemeContext.Provider value={themeState}>\n      {children}\n    </ThemeContext.Provider>\n  )\n}\n\nexport function useThemeContext() {\n  return useContext(ThemeContext)\n}\n"
  },
  {
    "path": "admin/inertia/tsconfig.json",
    "content": "{\n  \"extends\": \"@adonisjs/tsconfig/tsconfig.client.json\",\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"module\": \"ESNext\",\n    \"jsx\": \"react-jsx\",\n    \"paths\": {\n      \"~/*\": [\"./*\"],\n    },\n  },\n  \"include\": [\"./**/*.ts\", \"./**/*.tsx\", \"components/markdoc/nodes/heading.markdoc.js\"],\n}"
  },
  {
    "path": "admin/package.json",
    "content": "{\n  \"name\": \"project-nomad-admin\",\n  \"version\": \"0.0.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"license\": \"ISC\",\n  \"author\": \"Crosstalk Solutions, LLC\",\n  \"scripts\": {\n    \"start\": \"node bin/server.js\",\n    \"build\": \"node ace build\",\n    \"dev\": \"node ace serve --hmr\",\n    \"test\": \"node ace test\",\n    \"lint\": \"eslint .\",\n    \"format\": \"prettier --write .\",\n    \"typecheck\": \"tsc --noEmit\",\n    \"work:downloads\": \"node ace queue:work --queue=downloads\",\n    \"work:model-downloads\": \"node ace queue:work --queue=model-downloads\",\n    \"work:benchmarks\": \"node ace queue:work --queue=benchmarks\",\n    \"work:all\": \"node ace queue:work --all\"\n  },\n  \"imports\": {\n    \"#controllers/*\": \"./app/controllers/*.js\",\n    \"#exceptions/*\": \"./app/exceptions/*.js\",\n    \"#models/*\": \"./app/models/*.js\",\n    \"#mails/*\": \"./app/mails/*.js\",\n    \"#services/*\": \"./app/services/*.js\",\n    \"#listeners/*\": \"./app/listeners/*.js\",\n    \"#events/*\": \"./app/events/*.js\",\n    \"#middleware/*\": \"./app/middleware/*.js\",\n    \"#validators/*\": \"./app/validators/*.js\",\n    \"#providers/*\": \"./providers/*.js\",\n    \"#policies/*\": \"./app/policies/*.js\",\n    \"#abilities/*\": \"./app/abilities/*.js\",\n    \"#database/*\": \"./database/*.js\",\n    \"#tests/*\": \"./tests/*.js\",\n    \"#start/*\": \"./start/*.js\",\n    \"#config/*\": \"./config/*.js\",\n    \"#jobs/*\": \"./app/jobs/*.js\"\n  },\n  \"devDependencies\": {\n    \"@adonisjs/assembler\": \"^7.8.2\",\n    \"@adonisjs/eslint-config\": \"^2.0.0\",\n    \"@adonisjs/prettier-config\": \"^1.4.4\",\n    \"@adonisjs/tsconfig\": \"^1.4.0\",\n    \"@japa/assert\": \"^4.0.1\",\n    \"@japa/plugin-adonisjs\": \"^4.0.0\",\n    \"@japa/runner\": \"^4.2.0\",\n    \"@swc/core\": \"1.11.24\",\n    \"@tanstack/eslint-plugin-query\": \"^5.81.2\",\n    \"@types/dockerode\": \"^3.3.41\",\n    \"@types/luxon\": \"^3.6.2\",\n    \"@types/node\": \"^22.15.18\",\n    \"@types/react\": \"^19.1.8\",\n    \"@types/react-dom\": \"^19.1.6\",\n    \"@types/stopword\": \"^2.0.3\",\n    \"eslint\": \"^9.26.0\",\n    \"hot-hook\": \"^0.4.0\",\n    \"prettier\": \"^3.5.3\",\n    \"ts-node-maintained\": \"^10.9.5\",\n    \"typescript\": \"~5.8.3\",\n    \"vite\": \"^6.4.1\"\n  },\n  \"dependencies\": {\n    \"@adonisjs/auth\": \"^9.4.0\",\n    \"@adonisjs/core\": \"^6.18.0\",\n    \"@adonisjs/cors\": \"^2.2.1\",\n    \"@adonisjs/inertia\": \"^3.1.1\",\n    \"@adonisjs/lucid\": \"^21.8.2\",\n    \"@adonisjs/session\": \"^7.5.1\",\n    \"@adonisjs/shield\": \"^8.2.0\",\n    \"@adonisjs/static\": \"^1.1.1\",\n    \"@adonisjs/transmit\": \"^2.0.2\",\n    \"@adonisjs/transmit-client\": \"^1.0.0\",\n    \"@adonisjs/vite\": \"^4.0.0\",\n    \"@chonkiejs/core\": \"^0.0.7\",\n    \"@headlessui/react\": \"^2.2.4\",\n    \"@inertiajs/react\": \"^2.0.13\",\n    \"@markdoc/markdoc\": \"^0.5.2\",\n    \"@openzim/libzim\": \"^4.0.0\",\n    \"@protomaps/basemaps\": \"^5.7.0\",\n    \"@qdrant/js-client-rest\": \"^1.16.2\",\n    \"@tabler/icons-react\": \"^3.34.0\",\n    \"@tailwindcss/vite\": \"^4.1.10\",\n    \"@tanstack/react-query\": \"^5.81.5\",\n    \"@tanstack/react-query-devtools\": \"^5.83.0\",\n    \"@tanstack/react-virtual\": \"^3.13.12\",\n    \"@uppy/core\": \"^5.2.0\",\n    \"@uppy/dashboard\": \"^5.1.0\",\n    \"@uppy/react\": \"^5.1.1\",\n    \"@vinejs/vine\": \"^3.0.1\",\n    \"@vitejs/plugin-react\": \"^4.6.0\",\n    \"autoprefixer\": \"^10.4.21\",\n    \"axios\": \"^1.13.5\",\n    \"better-sqlite3\": \"^12.1.1\",\n    \"bullmq\": \"^5.65.1\",\n    \"cheerio\": \"^1.2.0\",\n    \"dockerode\": \"^4.0.7\",\n    \"edge.js\": \"^6.2.1\",\n    \"fast-xml-parser\": \"^5.5.6\",\n    \"fuse.js\": \"^7.1.0\",\n    \"luxon\": \"^3.6.1\",\n    \"maplibre-gl\": \"^4.7.1\",\n    \"mysql2\": \"^3.14.1\",\n    \"ollama\": \"^0.6.3\",\n    \"pdf-parse\": \"^2.4.5\",\n    \"pdf2pic\": \"^3.2.0\",\n    \"pino-pretty\": \"^13.0.0\",\n    \"pmtiles\": \"^4.3.0\",\n    \"postcss\": \"^8.5.6\",\n    \"react\": \"^19.1.0\",\n    \"react-adonis-transmit\": \"^1.0.1\",\n    \"react-dom\": \"^19.1.0\",\n    \"react-map-gl\": \"^8.1.0\",\n    \"react-markdown\": \"^10.1.0\",\n    \"reflect-metadata\": \"^0.2.2\",\n    \"remark-gfm\": \"^4.0.1\",\n    \"sharp\": \"^0.34.5\",\n    \"stopword\": \"^3.1.5\",\n    \"systeminformation\": \"^5.31.0\",\n    \"tailwindcss\": \"^4.1.10\",\n    \"tar\": \"^7.5.11\",\n    \"tesseract.js\": \"^7.0.0\",\n    \"url-join\": \"^5.0.0\",\n    \"yaml\": \"^2.8.0\"\n  },\n  \"hotHook\": {\n    \"boundaries\": [\n      \"./app/controllers/**/*.ts\",\n      \"./app/middleware/*.ts\"\n    ]\n  },\n  \"prettier\": \"@adonisjs/prettier-config\"\n}\n"
  },
  {
    "path": "admin/providers/map_static_provider.ts",
    "content": "import MapsStaticMiddleware from '#middleware/maps_static_middleware'\nimport logger from '@adonisjs/core/services/logger'\nimport type { ApplicationService } from '@adonisjs/core/types'\nimport { defineConfig } from '@adonisjs/static'\nimport { join } from 'path'\n\n/**\n * This is a bit of a hack to serve static files from the\n * /storage/maps directory using AdonisJS static middleware because\n * the middleware does not allow us to define a custom path we want\n * to serve (it always serves from public/ by default).\n *\n * We use the same other config options, just change the path\n * (though we could also separate config if needed).\n */\nexport default class MapStaticProvider {\n  constructor(protected app: ApplicationService) {}\n  register() {\n    this.app.container.singleton(MapsStaticMiddleware, () => {\n      const path = join(process.cwd(), '/storage/maps')\n      logger.info(`Maps static files will be served from ${path}`)\n      const config = this.app.config.get<any>('static', defineConfig({}))\n      return new MapsStaticMiddleware(path, config)\n    })\n  }\n}\n"
  },
  {
    "path": "admin/resources/views/inertia_layout.edge",
    "content": "<!DOCTYPE html>\n<html>\n\n<head>\n  <meta charset=\"utf-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/favicon-16x16.png\">\n  <link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/favicon-32x32.png\">\n  <link rel=\"icon\" type=\"image/png\" sizes=\"192x192\" href=\"/favicon-192x192.png\">\n  <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/favicon-180x180.png\">\n\n  <title inertia>Project N.O.M.A.D</title>\n\n  <script>\n    (function() {\n      try {\n        var theme = localStorage.getItem('nomad:theme');\n        if (theme === 'dark') {\n          document.documentElement.setAttribute('data-theme', 'dark');\n        }\n      } catch(e) {}\n    })();\n  </script>\n\n  @stack('dumper')\n  @viteReactRefresh()\n  @inertiaHead()\n  @vite(['inertia/app/app.tsx', `inertia/pages/${page.component}.tsx`])\n</head>\n\n<body class=\"min-h-screen w-screen font-sans\">\n  @inertia()\n</body>\n\n</html>"
  },
  {
    "path": "admin/start/env.ts",
    "content": "/*\n|--------------------------------------------------------------------------\n| Environment variables service\n|--------------------------------------------------------------------------\n|\n| The `Env.create` method creates an instance of the Env service. The\n| service validates the environment variables and also cast values\n| to JavaScript data types.\n|\n*/\n\nimport { Env } from '@adonisjs/core/env'\n\nexport default await Env.create(new URL('../', import.meta.url), {\n  NODE_ENV: Env.schema.enum(['development', 'production', 'test'] as const),\n  PORT: Env.schema.number(),\n  APP_KEY: Env.schema.string(),\n  HOST: Env.schema.string({ format: 'host' }),\n  URL: Env.schema.string(),\n  LOG_LEVEL: Env.schema.string(),\n  INTERNET_STATUS_TEST_URL: Env.schema.string.optional(),\n\n  /*\n  |----------------------------------------------------------\n  | Variables for configuring storage paths\n  |----------------------------------------------------------\n  */\n  NOMAD_STORAGE_PATH: Env.schema.string.optional(),\n\n  /*\n  |----------------------------------------------------------\n  | Variables for configuring session package\n  |----------------------------------------------------------\n  */\n  //SESSION_DRIVER: Env.schema.enum(['cookie', 'memory'] as const),\n\n  /*\n  |----------------------------------------------------------\n  | Variables for configuring the database package\n  |----------------------------------------------------------\n  */\n  DB_HOST: Env.schema.string({ format: 'host' }),\n  DB_PORT: Env.schema.number(),\n  DB_USER: Env.schema.string(),\n  DB_PASSWORD: Env.schema.string.optional(),\n  DB_DATABASE: Env.schema.string(),\n  DB_SSL: Env.schema.boolean.optional(),\n\n  /*\n  |----------------------------------------------------------\n  | Variables for configuring the Redis connection\n  |----------------------------------------------------------\n  */\n  REDIS_HOST: Env.schema.string({ format: 'host' }),\n  REDIS_PORT: Env.schema.number(),\n\n  /*\n  |----------------------------------------------------------\n  | Variables for configuring Project Nomad's external API URL\n  |----------------------------------------------------------\n  */\n  NOMAD_API_URL: Env.schema.string.optional(),\n})\n"
  },
  {
    "path": "admin/start/kernel.ts",
    "content": "/*\n|--------------------------------------------------------------------------\n| HTTP kernel file\n|--------------------------------------------------------------------------\n|\n| The HTTP kernel file is used to register the middleware with the server\n| or the router.\n|\n*/\n\nimport router from '@adonisjs/core/services/router'\nimport server from '@adonisjs/core/services/server'\n\n/**\n * The error handler is used to convert an exception\n * to an HTTP response.\n */\nserver.errorHandler(() => import('#exceptions/handler'))\n\n/**\n * The server middleware stack runs middleware on all the HTTP\n * requests, even if there is no route registered for\n * the request URL.\n */\nserver.use([\n  () => import('#middleware/container_bindings_middleware'),\n  () => import('@adonisjs/cors/cors_middleware'),\n  () => import('@adonisjs/vite/vite_middleware'),\n  () => import('@adonisjs/inertia/inertia_middleware'),\n  () => import('@adonisjs/static/static_middleware'),\n  () => import('#middleware/maps_static_middleware')\n])\n\n/**\n * The router middleware stack runs middleware on all the HTTP\n * requests with a registered route.\n */\nrouter.use([\n  () => import('@adonisjs/core/bodyparser_middleware'),\n  // () => import('@adonisjs/session/session_middleware'),\n  () => import('@adonisjs/shield/shield_middleware'),\n])\n\n/**\n * Named middleware collection must be explicitly assigned to\n * the routes or the routes group.\n */\nexport const middleware = router.named({})\n"
  },
  {
    "path": "admin/start/routes.ts",
    "content": "/*\n|--------------------------------------------------------------------------\n| Routes file\n|--------------------------------------------------------------------------\n|\n| The routes file is used for defining the HTTP routes.\n|\n*/\nimport BenchmarkController from '#controllers/benchmark_controller'\nimport ChatsController from '#controllers/chats_controller'\nimport DocsController from '#controllers/docs_controller'\nimport DownloadsController from '#controllers/downloads_controller'\nimport EasySetupController from '#controllers/easy_setup_controller'\nimport HomeController from '#controllers/home_controller'\nimport MapsController from '#controllers/maps_controller'\nimport OllamaController from '#controllers/ollama_controller'\nimport RagController from '#controllers/rag_controller'\nimport SettingsController from '#controllers/settings_controller'\nimport SystemController from '#controllers/system_controller'\nimport CollectionUpdatesController from '#controllers/collection_updates_controller'\nimport ZimController from '#controllers/zim_controller'\nimport router from '@adonisjs/core/services/router'\nimport transmit from '@adonisjs/transmit/services/main'\n\ntransmit.registerRoutes()\n\nrouter.get('/', [HomeController, 'index'])\nrouter.get('/home', [HomeController, 'home'])\nrouter.on('/about').renderInertia('about')\nrouter.get('/chat', [ChatsController, 'inertia'])\nrouter.get('/maps', [MapsController, 'index'])\nrouter.on('/knowledge-base').redirectToPath('/chat?knowledge_base=true') // redirect for legacy knowledge-base links\n\nrouter.get('/easy-setup', [EasySetupController, 'index'])\nrouter.get('/easy-setup/complete', [EasySetupController, 'complete'])\nrouter.get('/api/easy-setup/curated-categories', [EasySetupController, 'listCuratedCategories'])\nrouter.post('/api/manifests/refresh', [EasySetupController, 'refreshManifests'])\nrouter\n  .group(() => {\n    router.post('/check', [CollectionUpdatesController, 'checkForUpdates'])\n    router.post('/apply', [CollectionUpdatesController, 'applyUpdate'])\n    router.post('/apply-all', [CollectionUpdatesController, 'applyAllUpdates'])\n  })\n  .prefix('/api/content-updates')\n\nrouter\n  .group(() => {\n    router.get('/system', [SettingsController, 'system'])\n    router.get('/apps', [SettingsController, 'apps'])\n    router.get('/legal', [SettingsController, 'legal'])\n    router.get('/maps', [SettingsController, 'maps'])\n    router.get('/models', [SettingsController, 'models'])\n    router.get('/update', [SettingsController, 'update'])\n    router.get('/zim', [SettingsController, 'zim'])\n    router.get('/zim/remote-explorer', [SettingsController, 'zimRemote'])\n    router.get('/benchmark', [SettingsController, 'benchmark'])\n    router.get('/support', [SettingsController, 'support'])\n  })\n  .prefix('/settings')\n\nrouter\n  .group(() => {\n    router.get('/:slug', [DocsController, 'show'])\n    router.get('/', ({ response }) => {\n      // redirect to /docs/home if accessing root\n      response.redirect('/docs/home')\n    })\n  })\n  .prefix('/docs')\n\nrouter\n  .group(() => {\n    router.get('/regions', [MapsController, 'listRegions'])\n    router.get('/styles', [MapsController, 'styles'])\n    router.get('/curated-collections', [MapsController, 'listCuratedCollections'])\n    router.post('/fetch-latest-collections', [MapsController, 'fetchLatestCollections'])\n    router.post('/download-base-assets', [MapsController, 'downloadBaseAssets'])\n    router.post('/download-remote', [MapsController, 'downloadRemote'])\n    router.post('/download-remote-preflight', [MapsController, 'downloadRemotePreflight'])\n    router.post('/download-collection', [MapsController, 'downloadCollection'])\n    router.delete('/:filename', [MapsController, 'delete'])\n  })\n  .prefix('/api/maps')\n\nrouter\n  .group(() => {\n    router.get('/list', [DocsController, 'list'])\n  })\n  .prefix('/api/docs')\n\nrouter\n  .group(() => {\n    router.get('/jobs', [DownloadsController, 'index'])\n    router.get('/jobs/:filetype', [DownloadsController, 'filetype'])\n    router.delete('/jobs/:jobId', [DownloadsController, 'removeJob'])\n  })\n  .prefix('/api/downloads')\n\nrouter.get('/api/health', () => {\n  return { status: 'ok' }\n})\n\nrouter\n  .group(() => {\n    router.post('/chat', [OllamaController, 'chat'])\n    router.get('/models', [OllamaController, 'availableModels'])\n    router.post('/models', [OllamaController, 'dispatchModelDownload'])\n    router.delete('/models', [OllamaController, 'deleteModel'])\n    router.get('/installed-models', [OllamaController, 'installedModels'])\n  })\n  .prefix('/api/ollama')\n\nrouter\n  .group(() => {\n    router.get('/', [ChatsController, 'index'])\n    router.post('/', [ChatsController, 'store'])\n    router.delete('/all', [ChatsController, 'destroyAll'])\n    router.get('/:id', [ChatsController, 'show'])\n    router.put('/:id', [ChatsController, 'update'])\n    router.delete('/:id', [ChatsController, 'destroy'])\n    router.post('/:id/messages', [ChatsController, 'addMessage'])\n  })\n  .prefix('/api/chat/sessions')\n\nrouter.get('/api/chat/suggestions', [ChatsController, 'suggestions'])\n\nrouter\n  .group(() => {\n    router.post('/upload', [RagController, 'upload'])\n    router.get('/files', [RagController, 'getStoredFiles'])\n    router.delete('/files', [RagController, 'deleteFile'])\n    router.get('/active-jobs', [RagController, 'getActiveJobs'])\n    router.get('/job-status', [RagController, 'getJobStatus'])\n    router.post('/sync', [RagController, 'scanAndSync'])\n  })\n  .prefix('/api/rag')\n\nrouter\n  .group(() => {\n    router.get('/debug-info', [SystemController, 'getDebugInfo'])\n    router.get('/info', [SystemController, 'getSystemInfo'])\n    router.get('/internet-status', [SystemController, 'getInternetStatus'])\n    router.get('/services', [SystemController, 'getServices'])\n    router.post('/services/affect', [SystemController, 'affectService'])\n    router.post('/services/install', [SystemController, 'installService'])\n    router.post('/services/force-reinstall', [SystemController, 'forceReinstallService'])\n    router.post('/services/check-updates', [SystemController, 'checkServiceUpdates'])\n    router.get('/services/:name/available-versions', [SystemController, 'getAvailableVersions'])\n    router.post('/services/update', [SystemController, 'updateService'])\n    router.post('/subscribe-release-notes', [SystemController, 'subscribeToReleaseNotes'])\n    router.get('/latest-version', [SystemController, 'checkLatestVersion'])\n    router.post('/update', [SystemController, 'requestSystemUpdate'])\n    router.get('/update/status', [SystemController, 'getSystemUpdateStatus'])\n    router.get('/update/logs', [SystemController, 'getSystemUpdateLogs'])\n    router.get('/settings', [SettingsController, 'getSetting'])\n    router.patch('/settings', [SettingsController, 'updateSetting'])\n  })\n  .prefix('/api/system')\n\nrouter\n  .group(() => {\n    router.get('/list', [ZimController, 'list'])\n    router.get('/list-remote', [ZimController, 'listRemote'])\n    router.get('/curated-categories', [ZimController, 'listCuratedCategories'])\n    router.post('/download-remote', [ZimController, 'downloadRemote'])\n    router.post('/download-category-tier', [ZimController, 'downloadCategoryTier'])\n\n    router.get('/wikipedia', [ZimController, 'getWikipediaState'])\n    router.post('/wikipedia/select', [ZimController, 'selectWikipedia'])\n    router.delete('/:filename', [ZimController, 'delete'])\n  })\n  .prefix('/api/zim')\n\nrouter\n  .group(() => {\n    router.post('/run', [BenchmarkController, 'run'])\n    router.post('/run/system', [BenchmarkController, 'runSystem'])\n    router.post('/run/ai', [BenchmarkController, 'runAI'])\n    router.get('/results', [BenchmarkController, 'results'])\n    router.get('/results/latest', [BenchmarkController, 'latest'])\n    router.get('/results/:id', [BenchmarkController, 'show'])\n    router.post('/submit', [BenchmarkController, 'submit'])\n    router.post('/builder-tag', [BenchmarkController, 'updateBuilderTag'])\n    router.get('/comparison', [BenchmarkController, 'comparison'])\n    router.get('/status', [BenchmarkController, 'status'])\n    router.get('/settings', [BenchmarkController, 'settings'])\n    router.post('/settings', [BenchmarkController, 'updateSettings'])\n  })\n  .prefix('/api/benchmark')\n"
  },
  {
    "path": "admin/tailwind.config.ts",
    "content": "/** @type {import('tailwindcss').Config} */\n\nexport default {\n  content: [\"./src/**/*.{js,jsx,ts,tsx}\", \"./index.html\"],\n  theme: {\n    extend: {\n      colors: {\n        desert: \"#EADAB9\",\n        \"desert-green-light\": \"#BABAAA\",\n      },\n    },\n  },\n};\n"
  },
  {
    "path": "admin/tests/bootstrap.ts",
    "content": "import { assert } from '@japa/assert'\nimport app from '@adonisjs/core/services/app'\nimport type { Config } from '@japa/runner/types'\nimport { pluginAdonisJS } from '@japa/plugin-adonisjs'\nimport testUtils from '@adonisjs/core/services/test_utils'\n\n/**\n * This file is imported by the \"bin/test.ts\" entrypoint file\n */\n\n/**\n * Configure Japa plugins in the plugins array.\n * Learn more - https://japa.dev/docs/runner-config#plugins-optional\n */\nexport const plugins: Config['plugins'] = [assert(), pluginAdonisJS(app)]\n\n/**\n * Configure lifecycle function to run before and after all the\n * tests.\n *\n * The setup functions are executed before all the tests\n * The teardown functions are executed after all the tests\n */\nexport const runnerHooks: Required<Pick<Config, 'setup' | 'teardown'>> = {\n  setup: [],\n  teardown: [],\n}\n\n/**\n * Configure suites by tapping into the test suite instance.\n * Learn more - https://japa.dev/docs/test-suites#lifecycle-hooks\n */\nexport const configureSuite: Config['configureSuite'] = (suite) => {\n  if (['browser', 'functional', 'e2e'].includes(suite.name)) {\n    return suite.setup(() => testUtils.httpServer().start())\n  }\n}\n"
  },
  {
    "path": "admin/tsconfig.json",
    "content": "{\n  \"extends\": \"@adonisjs/tsconfig/tsconfig.app.json\",\n  \"compilerOptions\": {\n    \"rootDir\": \"./\",\n    \"outDir\": \"./build\"\n  },\n  \"exclude\": [\"./inertia/**/*\", \"node_modules\", \"build\"]\n}\n"
  },
  {
    "path": "admin/types/benchmark.ts",
    "content": "import BenchmarkResult from '#models/benchmark_result'\n\n// Benchmark type identifiers\nexport type BenchmarkType = 'full' | 'system' | 'ai'\n\n// Benchmark execution status\nexport type BenchmarkStatus =\n  | 'idle'\n  | 'starting'\n  | 'detecting_hardware'\n  | 'running_cpu'\n  | 'running_memory'\n  | 'running_disk_read'\n  | 'running_disk_write'\n  | 'downloading_ai_model'\n  | 'running_ai'\n  | 'calculating_score'\n  | 'completed'\n  | 'error'\n\n// Hardware detection types\nexport type DiskType = 'ssd' | 'hdd' | 'nvme' | 'unknown'\n\nexport type HardwareInfo = Pick<\n  BenchmarkResult,\n  'cpu_model' | 'cpu_cores' | 'cpu_threads' | 'ram_bytes' | 'disk_type' | 'gpu_model'\n>\n\n// Individual benchmark scores\nexport type SystemScores = Pick<\n  BenchmarkResult,\n  'cpu_score' | 'memory_score' | 'disk_read_score' | 'disk_write_score'\n>\n\nexport type AIScores = Pick<\n  BenchmarkResult,\n  'ai_tokens_per_second' | 'ai_model_used' | 'ai_time_to_first_token'\n>\n\n// Slim version for lists\nexport type BenchmarkResultSlim = Pick<\n  BenchmarkResult,\n  | 'id'\n  | 'benchmark_id'\n  | 'benchmark_type'\n  | 'nomad_score'\n  | 'submitted_to_repository'\n  | 'created_at'\n  | 'builder_tag'\n> & {\n  cpu_model: string\n  gpu_model: string | null\n}\n\n// Benchmark settings key-value store\nexport type BenchmarkSettingKey =\n  | 'allow_anonymous_submission'\n  | 'installation_id'\n  | 'last_benchmark_run'\n\nexport type BenchmarkSettings = {\n  allow_anonymous_submission: boolean\n  installation_id: string | null\n  last_benchmark_run: string | null\n}\n\n// Progress update for real-time feedback\nexport type BenchmarkProgress = {\n  status: BenchmarkStatus\n  progress: number\n  message: string\n  current_stage: string\n  timestamp: string\n}\n\n// API request types\nexport type RunBenchmarkParams = {\n  benchmark_type: BenchmarkType\n}\n\nexport type SubmitBenchmarkParams = {\n  benchmark_id?: string\n}\n\n// API response types\nexport type RunBenchmarkResponse = {\n  success: boolean\n  job_id: string\n  benchmark_id: string\n  message: string\n}\n\nexport type BenchmarkResultsResponse = {\n  results: BenchmarkResult[]\n  total: number\n}\n\nexport type SubmitBenchmarkResponse = {\n  success: true\n  repository_id: string\n  percentile: number\n} | {\n  success: false\n  error: string\n}\n\nexport type UpdateBuilderTagResponse = {\n  success: true,\n  builder_tag: string | null\n} | {\n  success: false,\n  error: string\n}\n\n// Central repository submission payload (privacy-first)\nexport type RepositorySubmission = Pick<\n  BenchmarkResult,\n  | 'cpu_model'\n  | 'cpu_cores'\n  | 'cpu_threads'\n  | 'disk_type'\n  | 'gpu_model'\n  | 'cpu_score'\n  | 'memory_score'\n  | 'disk_read_score'\n  | 'disk_write_score'\n  | 'ai_tokens_per_second'\n  | 'ai_time_to_first_token'\n  | 'nomad_score'\n> & {\n  nomad_version: string\n  benchmark_version: string\n  ram_gb: number\n  builder_tag: string | null // null = anonymous submission\n}\n\n// Central repository response types\nexport type RepositorySubmitResponse = {\n  success: boolean\n  repository_id: string\n  percentile: number\n}\n\nexport type RepositoryStats = {\n  total_submissions: number\n  average_score: number\n  median_score: number\n  top_score: number\n  percentiles: {\n    p10: number\n    p25: number\n    p50: number\n    p75: number\n    p90: number\n  }\n}\n\nexport type LeaderboardEntry = Pick<BenchmarkResult, 'cpu_model' | 'gpu_model' | 'nomad_score'> & {\n  rank: number\n  submitted_at: string\n}\n\nexport type ComparisonResponse = {\n  matching_submissions: number\n  average_score: number\n  your_percentile: number | null\n}\n\n// Score calculation weights (for reference in UI)\nexport type ScoreWeights = {\n  ai_tokens_per_second: number\n  cpu: number\n  memory: number\n  ai_ttft: number\n  disk_read: number\n  disk_write: number\n}\n\n// Default weights as defined in plan\nexport const DEFAULT_SCORE_WEIGHTS: ScoreWeights = {\n  ai_tokens_per_second: 0.3,\n  cpu: 0.25,\n  memory: 0.15,\n  ai_ttft: 0.1,\n  disk_read: 0.1,\n  disk_write: 0.1,\n}\n\n// Benchmark job parameters\nexport type RunBenchmarkJobParams = {\n  benchmark_id: string\n  benchmark_type: BenchmarkType\n  include_ai: boolean\n}\n\n// sysbench result parsing types\nexport type SysbenchCpuResult = {\n  events_per_second: number\n  total_time: number\n  total_events: number\n}\n\nexport type SysbenchMemoryResult = {\n  operations_per_second: number\n  transfer_rate_mb_per_sec: number\n  total_time: number\n}\n\nexport type SysbenchDiskResult = {\n  reads_per_second: number\n  writes_per_second: number\n  read_mb_per_sec: number\n  write_mb_per_sec: number\n  total_time: number\n}\n"
  },
  {
    "path": "admin/types/chat.ts",
    "content": "export interface ChatMessage {\n  id: string\n  role: 'system' | 'user' | 'assistant'\n  content: string\n  timestamp: Date\n  isStreaming?: boolean\n  thinking?: string\n  isThinking?: boolean\n  thinkingDuration?: number\n}\n\nexport interface ChatSession {\n  id: string\n  title: string\n  lastMessage?: string\n  timestamp: Date\n}\n"
  },
  {
    "path": "admin/types/collections.ts",
    "content": "export type SpecResource = {\n  id: string\n  version: string\n  title: string\n  description: string\n  url: string\n  size_mb: number\n}\n\nexport type SpecTier = {\n  name: string\n  slug: string\n  description: string\n  recommended?: boolean\n  includesTier?: string\n  resources: SpecResource[]\n}\n\nexport type SpecCategory = {\n  name: string\n  slug: string\n  icon: string\n  description: string\n  language: string\n  tiers: SpecTier[]\n}\n\nexport type SpecCollection = {\n  name: string\n  slug: string\n  description: string\n  icon: string\n  language: string\n  resources: SpecResource[]\n}\n\nexport type ZimCategoriesSpec = {\n  spec_version: string\n  categories: SpecCategory[]\n}\n\nexport type MapsSpec = {\n  spec_version: string\n  collections: SpecCollection[]\n}\n\nexport type WikipediaOption = {\n  id: string\n  name: string\n  description: string\n  size_mb: number\n  url: string | null\n  version: string | null\n}\n\nexport type WikipediaSpec = {\n  spec_version: string\n  options: WikipediaOption[]\n}\n\nexport type ManifestType = 'zim_categories' | 'maps' | 'wikipedia'\n\nexport type ResourceStatus = 'installed' | 'not_installed' | 'update_available'\n\nexport type CategoryWithStatus = SpecCategory & {\n  installedTierSlug?: string\n}\n\nexport type CollectionWithStatus = SpecCollection & {\n  all_installed: boolean\n  installed_count: number\n  total_count: number\n}\n\nexport type ResourceUpdateCheckRequest = {\n  resources: Array<{\n    resource_id: string\n    resource_type: 'zim' | 'map'\n    installed_version: string\n  }>\n}\n\nexport type ResourceUpdateInfo = {\n  resource_id: string\n  resource_type: 'zim' | 'map'\n  installed_version: string\n  latest_version: string\n  download_url: string\n}\n\nexport type ContentUpdateCheckResult = {\n  updates: ResourceUpdateInfo[]\n  checked_at: string\n  error?: string\n}\n"
  },
  {
    "path": "admin/types/docker.ts",
    "content": "\nexport type DockerComposeServiceConfig = {\n    image: string;\n    container_name: string;\n    restart: string;\n    ports: string[];\n    environment?: Record<string, string>;\n    volumes?: string[];\n    networks?: string[];\n}"
  },
  {
    "path": "admin/types/downloads.ts",
    "content": "export type DoResumableDownloadParams = {\n  url: string\n  filepath: string\n  timeout: number\n  allowedMimeTypes: string[]\n  signal?: AbortSignal\n  onProgress?: (progress: DoResumableDownloadProgress) => void\n  onComplete?: (url: string, path: string) => void | Promise<void>\n  forceNew?: boolean\n}\n\nexport type DoResumableDownloadWithRetryParams = DoResumableDownloadParams & {\n  max_retries?: number\n  retry_delay?: number\n  onAttemptError?: (error: Error, attempt: number) => void\n}\n\nexport type DoResumableDownloadProgress = {\n  downloadedBytes: number\n  totalBytes: number\n  lastProgressTime: number\n  lastDownloadedBytes: number\n  url: string\n}\n\nexport type RunDownloadJobParams = Omit<\n  DoResumableDownloadParams,\n  'onProgress' | 'onComplete' | 'signal'\n> & {\n  filetype: string\n  resourceMetadata?: {\n    resource_id: string\n    version: string\n    collection_ref: string | null\n  }\n}\n\nexport type DownloadJobWithProgress = {\n  jobId: string\n  url: string\n  progress: number\n  filepath: string\n  filetype: string\n  status?: 'active' | 'failed'\n  failedReason?: string\n}\n\n// Wikipedia selector types\nexport type WikipediaOption = {\n  id: string\n  name: string\n  description: string\n  size_mb: number\n  url: string | null\n}\n\nexport type WikipediaOptionsFile = {\n  options: WikipediaOption[]\n}\n\nexport type WikipediaCurrentSelection = {\n  optionId: string\n  status: 'none' | 'downloading' | 'installed' | 'failed'\n  filename: string | null\n  url: string | null\n}\n\nexport type WikipediaState = {\n  options: WikipediaOption[]\n  currentSelection: WikipediaCurrentSelection | null\n}\n"
  },
  {
    "path": "admin/types/files.ts",
    "content": "/* General file transfer/download utility types */\n\nexport type FileEntry =\n  | {\n      type: 'file'\n      key: string\n      name: string\n    }\n  | {\n      type: 'directory'\n      prefix: string\n      name: string\n    }\n\nexport type DownloadProgress = {\n  downloaded_bytes: number\n  total_bytes: number\n  percentage: number\n  speed: string\n  time_remaining: number\n}\n\nexport type DownloadOptions = {\n  max_retries?: number\n  retry_delay?: number\n  chunk_size?: number\n  timeout?: number\n  onError?: (error: Error) => void\n  onComplete?: (filepath: string) => void\n}\n\nexport type DownloadRemoteSuccessCallback = (urls: string[], restart: boolean) => Promise<void>"
  },
  {
    "path": "admin/types/kv_store.ts",
    "content": "\nexport const KV_STORE_SCHEMA = {\n  'chat.suggestionsEnabled':    'boolean',\n  'chat.lastModel':             'string',\n  'rag.docsEmbedded':           'boolean',\n  'system.updateAvailable':     'boolean',\n  'system.latestVersion':       'string',\n  'system.earlyAccess':         'boolean',\n  'ui.hasVisitedEasySetup':     'boolean',\n  'ui.theme':                   'string',\n  'ai.assistantCustomName':     'string',\n  'gpu.type':                   'string',\n} as const\n\ntype KVTagToType<T extends string> = T extends 'boolean' ? boolean : string\n\nexport type KVStoreKey = keyof typeof KV_STORE_SCHEMA\nexport type KVStoreValue<K extends KVStoreKey> = KVTagToType<(typeof KV_STORE_SCHEMA)[K]>\n"
  },
  {
    "path": "admin/types/maps.ts",
    "content": "export type BaseStylesFile = {\n  version: number\n  sources: {\n    [key: string]: MapSource\n  }\n  layers: MapLayer[]\n  sprite: string\n  glyphs: string\n}\n\nexport type MapSource = {\n  type: 'vector' | 'raster' | 'raster-dem' | 'geojson' | 'image' | 'video'\n  attribution?: string\n  url: string\n}\n\nexport type MapLayer = {\n  'id': string\n  'type': string\n  'source'?: string\n  'source-layer'?: string\n  [key: string]: any\n}\n"
  },
  {
    "path": "admin/types/ollama.ts",
    "content": "export type NomadOllamaModel = {\n  id: string\n  name: string\n  description: string\n  estimated_pulls: string\n  model_last_updated: string\n  first_seen: string\n  tags: NomadOllamaModelTag[]\n}\n\nexport type NomadOllamaModelTag = {\n  name: string\n  size: string\n  context: string\n  input: string\n  cloud: boolean\n  thinking: boolean\n}\n\nexport type NomadOllamaModelAPIResponse = {\n  success: boolean\n  message: string\n  models: NomadOllamaModel[]\n}\n\nexport type OllamaChatMessage = {\n  role: 'system' | 'user' | 'assistant'\n  content: string\n}\n\nexport type OllamaChatRequest = {\n  model: string\n  messages: OllamaChatMessage[]\n  stream?: boolean\n  sessionId?: number\n}\n\nexport type OllamaChatResponse = {\n  model: string\n  created_at: string\n  message: {\n    role: string\n    content: string\n  }\n  done: boolean\n}\n"
  },
  {
    "path": "admin/types/rag.ts",
    "content": "export type EmbedJobWithProgress = {\n  jobId: string\n  fileName: string\n  filePath: string\n  progress: number\n  status: string\n}\n\nexport type ProcessAndEmbedFileResponse = {\n  success: boolean\n  message: string\n  chunks?: number\n  hasMoreBatches?: boolean\n  articlesProcessed?: number\n  totalArticles?: number\n}\nexport type ProcessZIMFileResponse = ProcessAndEmbedFileResponse\n\nexport type RAGResult = {\n  text: string\n  score: number\n  keywords: string\n  chunk_index: number\n  created_at: number\n  article_title?: string\n  section_title?: string\n  full_title?: string\n  hierarchy?: string\n  document_id?: string\n  content_type?: string\n  source?: string\n}\n\nexport type RerankedRAGResult = Omit<RAGResult, 'keywords'> & {\n  finalScore: number\n}"
  },
  {
    "path": "admin/types/services.ts",
    "content": "import Service from '#models/service'\n\nexport type ServiceSlim = Pick<\n  Service,\n  | 'id'\n  | 'service_name'\n  | 'installed'\n  | 'installation_status'\n  | 'ui_location'\n  | 'friendly_name'\n  | 'description'\n  | 'icon'\n  | 'powered_by'\n  | 'display_order'\n  | 'container_image'\n  | 'available_update_version'\n> & { status?: string }\n"
  },
  {
    "path": "admin/types/system.ts",
    "content": "import { Systeminformation } from 'systeminformation'\n\nexport type GpuHealthStatus = {\n  status: 'ok' | 'passthrough_failed' | 'no_gpu' | 'ollama_not_installed'\n  hasNvidiaRuntime: boolean\n  ollamaGpuAccessible: boolean\n}\n\nexport type SystemInformationResponse = {\n  cpu: Systeminformation.CpuData\n  mem: Systeminformation.MemData\n  os: Systeminformation.OsData\n  disk: NomadDiskInfo[]\n  currentLoad: Systeminformation.CurrentLoadData\n  fsSize: Systeminformation.FsSizeData[]\n  uptime: Systeminformation.TimeData\n  graphics: Systeminformation.GraphicsData\n  gpuHealth?: GpuHealthStatus\n}\n\n// Type inferrence is not working properly with usePage and shared props, so we define this type manually\nexport type UsePageProps = {\n  appVersion: string\n  environment: string\n}\n\nexport type LSBlockDevice = {\n  name: string\n  size: string\n  type: string\n  model: string | null\n  serial: string | null\n  vendor: string | null\n  rota: boolean | null\n  tran: string | null\n  children?: LSBlockDevice[]\n}\n\nexport type NomadDiskInfoRaw = {\n  diskLayout: {\n    blockdevices: LSBlockDevice[]\n  }\n  fsSize: {\n    fs: string\n    size: number\n    used: number\n    available: number\n    use: number\n    mount: string\n  }[]\n}\n\nexport type NomadDiskInfo = {\n  name: string\n  model: string\n  vendor: string\n  rota: boolean\n  tran: string\n  size: string\n  totalUsed: number\n  totalSize: number\n  percentUsed: number\n  filesystems: {\n    fs: string\n    mount: string\n    used: number\n    size: number\n    percentUsed: number\n  }[]\n}\n\nexport type SystemUpdateStatus = {\n  stage: 'idle' | 'starting' | 'pulling' | 'pulled' | 'recreating' | 'complete' | 'error'\n  progress: number\n  message: string\n  timestamp: string\n}\n\n\nexport type CheckLatestVersionResult = {\n  success: boolean,\n  updateAvailable: boolean,\n  currentVersion: string,\n  latestVersion: string,\n  message?: string\n}"
  },
  {
    "path": "admin/types/util.ts",
    "content": "\n\n"
  },
  {
    "path": "admin/types/zim.ts",
    "content": "import { FileEntry } from './files.js'\n\nexport type ZimFileWithMetadata = FileEntry & {\n  title: string | null\n  summary: string | null\n  author: string | null\n  size_bytes: number | null\n}\n\nexport type ListZimFilesResponse = {\n  files: ZimFileWithMetadata[]\n  next?: string\n}\n\nexport type ListRemoteZimFilesResponse = {\n  items: RemoteZimFileEntry[]\n  has_more: boolean\n  total_count: number\n}\n\nexport type RawRemoteZimFileEntry = {\n  'id': string\n  'title': string\n  'updated': string\n  'summary': string\n  'language': string\n  'name': string\n  'flavour': string\n  'category': string\n  'tags': string\n  'articleCount': number\n  'mediaCount': number\n  'link': Record<string, string>[]\n  'author': {\n    name: string\n  }\n  'publisher': {\n    name: string\n  }\n  'dc:issued': string\n}\n\nexport type RawListRemoteZimFilesResponse = {\n  '?xml': string\n  'feed': {\n    id: string\n    link: string[]\n    title: string\n    updated: string\n    totalResults: number\n    startIndex: number\n    itemsPerPage: number\n    entry?: RawRemoteZimFileEntry | RawRemoteZimFileEntry[]\n  }\n}\n\nexport type RemoteZimFileEntry = {\n  id: string\n  title: string\n  updated: string\n  summary: string\n  size_bytes: number\n  download_url: string\n  author: string\n  file_name: string\n}\n\nexport type ExtractZIMContentOptions = {\n  strategy?: ExtractZIMChunkingStrategy\n  maxArticles?: number\n  onProgress?: (processedArticles: number, totalArticles: number) => void\n  // Batch processing options to avoid lock timeouts\n  startOffset?: number  // Article index to start from for resuming\n  batchSize?: number    // Max articles to process in this batch\n}\n\nexport type ExtractZIMChunkingStrategy = 'structured' | 'simple'\n\nexport type ZIMArchiveMetadata = {\n  title: string\n  creator: string\n  publisher: string\n  date: string\n  language: string\n  description: string\n}\n\nexport type ZIMContentChunk = {\n  // Content\n  text: string\n\n  // Article-level context\n  articleTitle: string\n  articlePath: string\n\n  // Section-level context for structured chunks\n  sectionTitle: string\n  fullTitle: string // Combined \"Article Title - Section Title\"\n  hierarchy: string // Breadcrumb trail\n  sectionLevel?: number // Heading level (2=h2, 3=h3, etc.)\n\n  // Document grouping\n  documentId: string // Same for all chunks from one article\n\n  // Archive metadata\n  archiveMetadata: ZIMArchiveMetadata\n\n  // Extraction metadata\n  strategy: ExtractZIMChunkingStrategy\n}"
  },
  {
    "path": "admin/util/docs.ts",
    "content": "\nexport const streamToString = async (stream: NodeJS.ReadableStream): Promise<string> => {\n    const chunks: Buffer[] = [];\n    for await (const chunk of stream) {\n        chunks.push(Buffer.from(chunk));\n    }\n    return Buffer.concat(chunks).toString('utf-8');\n};"
  },
  {
    "path": "admin/util/files.ts",
    "content": "import fs from 'fs';\nimport path from 'path';\n\nexport async function chmodRecursive(\n    dirPath: string,\n    dirPermissions = 0o755,  // rwxr-xr-x for directories\n    filePermissions = 0o644  // rw-r--r-- for files\n) {\n    try {\n        const stats = await fs.promises.stat(dirPath);\n\n        if (stats.isDirectory()) {\n            await fs.promises.chmod(dirPath, dirPermissions);\n\n            // Process directory contents\n            const items = await fs.promises.readdir(dirPath);\n            for (const item of items) {\n                const itemPath = path.join(dirPath, item);\n                await chmodRecursive(itemPath, dirPermissions, filePermissions);\n            }\n        } else {\n            await fs.promises.chmod(dirPath, filePermissions);\n        }\n    } catch (error) {\n        console.error(`Error setting permissions on ${dirPath}:`, error.message);\n    }\n}\n\n\nexport async function chownRecursive(targetPath: string, uid: number, gid: number) {\n    try {\n        const stats = await fs.promises.stat(targetPath);\n\n        await fs.promises.chown(targetPath, uid, gid);\n        \n        if (stats.isDirectory()) {\n            const items = await fs.promises.readdir(targetPath);\n            for (const item of items) {\n                await chownRecursive(path.join(targetPath, item), uid, gid);\n            }\n        }\n    } catch (error) {\n        console.error(`Error changing ownership on ${targetPath}:`, error.message);\n    }\n}"
  },
  {
    "path": "admin/util/zim.ts",
    "content": "import { RawListRemoteZimFilesResponse, RawRemoteZimFileEntry } from '../types/zim.js'\n\nexport function isRawListRemoteZimFilesResponse(obj: any): obj is RawListRemoteZimFilesResponse {\n  if (!(obj && typeof obj === 'object' && 'feed' in obj)) {\n    return false\n  }\n  if (!obj.feed || typeof obj.feed !== 'object') {\n    return false\n  }\n  if (!('entry' in obj.feed)) {\n    return true // entry is optional and may be missing if there are no results\n  }\n\n  if ('entry' in obj.feed && typeof obj.feed.entry !== 'object') {\n    return false // If entry exists, it must be an object or array\n  }\n\n  return true\n}\n\nexport function isRawRemoteZimFileEntry(obj: any): obj is RawRemoteZimFileEntry {\n  return obj && typeof obj === 'object' && 'id' in obj && 'title' in obj && 'summary' in obj\n}\n"
  },
  {
    "path": "admin/views/inertia_layout.edge",
    "content": "<!DOCTYPE html>\n<html>\n\n<head>\n  <meta charset=\"utf-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\n  <title inertia>Project N.O.M.A.D</title>\n\n  <link rel=\"preconnect\" href=\"https://fonts.bunny.net\">\n  <link href=\"https://fonts.bunny.net/css?family=instrument-sans:400,400i,500,500i,600,600i,700,700i\" rel=\"stylesheet\" />\n\n  <style>\n    :root {\n      --sand-1: #fdfdfc;\n      --sand-2: #f9f9f8;\n      --sand-3: #f1f0ef;\n      --sand-4: #e9e8e6;\n      --sand-5: #e2e1de;\n      --sand-6: #dad9d6;\n      --sand-7: #cfceca;\n      --sand-8: #bcbbb5;\n      --sand-9: #8d8d86;\n      --sand-10: #82827c;\n      --sand-11: #63635e;\n      --sand-12: #21201c;\n    }\n  </style>\n\n  <!-- <script src=\"https://cdn.tailwindcss.com\"></script> -->\n\n <!-- <script>\n    tailwind.config = {\n      theme: {\n        extend: {\n          fontFamily: {\n            sans: ['Instrument Sans', 'sans-serif'],\n          },\n          colors: {\n            primary: {\n              DEFAULT: '#5A45FF',\n            },\n            sand: {\n              1: 'var(--sand-1)',\n              2: 'var(--sand-2)',\n              3: 'var(--sand-3)',\n              4: 'var(--sand-4)',\n              5: 'var(--sand-5)',\n              6: 'var(--sand-6)',\n              7: 'var(--sand-7)',\n              8: 'var(--sand-8)',\n              9: 'v and import it herear(--sand-9)',\n              10: 'var(--sand-10)',\n              11: 'var(--sand-11)',\n              12: 'var(--sand-12)',\n            },\n          },\n        },\n      },\n    }\n  </script> -->\n\n  @stack('dumper')\n  @viteReactRefresh()\n  @inertiaHead()\n  @vite(['inertia/app/app.tsx', `inertia/pages/${page.component}.tsx`])\n</head>\n\n<body class=\"min-h-screen w-screen font-sans\">\n  @inertia()\n</body>\n\n</html>"
  },
  {
    "path": "admin/vite.config.ts",
    "content": "import { defineConfig } from 'vite'\nimport { getDirname } from '@adonisjs/core/helpers'\nimport inertia from '@adonisjs/inertia/client'\nimport react from '@vitejs/plugin-react'\nimport adonisjs from '@adonisjs/vite/client'\nimport tailwindcss from '@tailwindcss/vite'\n\n\nexport default defineConfig({\n  plugins: [inertia({ ssr: { enabled: false } }), react(), tailwindcss(), adonisjs({ entrypoints: ['inertia/app/app.tsx'], reload: ['resources/views/**/*.edge'] })],\n\n  /**\n   * Define aliases for importing modules from\n   * your frontend code\n   */\n  resolve: {\n    alias: {\n      '~/': `${getDirname(import.meta.url)}/inertia/`,\n    },\n  },\n})\n"
  },
  {
    "path": "collections/CATEGORIES-TODO.md",
    "content": "# Kiwix Categories To-Do List\n\nPotential categories to add to the tiered collections system in `kiwix-categories.json`.\n\n## Current Categories (Completed)\n- [x] Medicine - Medical references, first aid, emergency care\n- [x] Survival & Preparedness - Food prep, prepper videos, repair guides\n- [x] Education & Reference - Wikipedia, textbooks, TED talks\n\n---\n\n## High Priority\n\n### Technology & Programming\nStack Overflow, developer documentation, coding tutorials\n- Stack Overflow (multiple tags available)\n- DevDocs documentation\n- freeCodeCamp\n- Programming language references\n\n### Children & Family\nAge-appropriate educational content for kids\n- Wikipedia for Schools\n- Wikibooks Children's Bookshelf\n- Khan Academy Kids (via Kolibri - separate system)\n- Storybooks, fairy tales\n\n### Trades & Vocational\nPractical skills for building, fixing, and maintaining\n- Electrical wiring guides\n- Plumbing basics\n- Automotive repair\n- Woodworking\n- Welding fundamentals\n\n### Agriculture & Gardening\nFood production and farming (expand beyond what's in Survival)\n- Practical Plants database\n- Permaculture guides\n- Seed saving\n- Animal husbandry\n- Composting and soil management\n\n---\n\n## Medium Priority\n\n### Languages & Reference\nDictionaries, language learning, translation\n- Wiktionary (multiple languages)\n- Language learning resources\n- Translation dictionaries\n- Grammar guides\n\n### History & Culture\nHistorical knowledge and cultural encyclopedias\n- Wikipedia History portal content\n- Historical documents\n- Cultural archives\n- Biographies\n\n### Legal & Civic\nLaws, rights, and civic procedures\n- Legal references\n- Constitutional documents\n- Civic procedures\n- Rights and responsibilities\n\n### Communications\nEmergency and amateur radio, networking\n- Ham radio guides\n- Emergency communication protocols\n- Basic networking/IT\n- Signal procedures\n\n---\n\n## Nice To Have\n\n### Entertainment\nRecreational reading and activities\n- Project Gutenberg (fiction categories)\n- Chess tutorials\n- Puzzles and games\n- Music theory\n\n### Religion & Philosophy\nSpiritual and philosophical texts\n- Religious texts (various traditions)\n- Philosophy references\n- Ethics guides\n\n### Regional/Non-English Bundles\nContent in other languages\n- Spanish language bundle\n- French language bundle\n- Other major languages\n\n---\n\n## Notes\n\n- Each category should have 3 tiers: Essential, Standard, Comprehensive\n- Higher tiers include all content from lower tiers via `includesTier`\n- Check Kiwix catalog for available ZIM files: https://download.kiwix.org/zim/\n- Consider storage constraints - Essential tiers should be <500MB ideally\n- Mark one tier as `recommended: true` (usually Essential)\n"
  },
  {
    "path": "collections/kiwix-categories.json",
    "content": "{\n  \"spec_version\": \"2026-02-11\",\n  \"categories\": [\n    {\n      \"name\": \"Medicine\",\n      \"slug\": \"medicine\",\n      \"icon\": \"IconStethoscope\",\n      \"description\": \"Medical references, guides, and encyclopedias for healthcare information and emergency preparedness.\",\n      \"language\": \"en\",\n      \"tiers\": [\n        {\n          \"name\": \"Essential\",\n          \"slug\": \"medicine-essential\",\n          \"description\": \"Core medical references for first aid, medications, and emergency care. Start here.\",\n          \"recommended\": true,\n          \"resources\": [\n            {\n              \"id\": \"zimgit-medicine_en\",\n              \"version\": \"2024-08\",\n              \"title\": \"Medical Library\",\n              \"description\": \"Field and emergency medicine books and guides\",\n              \"url\": \"https://download.kiwix.org/zim/other/zimgit-medicine_en_2024-08.zim\",\n              \"size_mb\": 67\n            },\n            {\n              \"id\": \"nhs.uk_en_medicines\",\n              \"version\": \"2025-12\",\n              \"title\": \"NHS Medicines A to Z\",\n              \"description\": \"How medicines work, dosages, side effects, and interactions\",\n              \"url\": \"https://download.kiwix.org/zim/zimit/nhs.uk_en_medicines_2025-12.zim\",\n              \"size_mb\": 16\n            },\n            {\n              \"id\": \"fas-military-medicine_en\",\n              \"version\": \"2025-06\",\n              \"title\": \"Military Medicine\",\n              \"description\": \"Tactical and field medicine manuals\",\n              \"url\": \"https://download.kiwix.org/zim/zimit/fas-military-medicine_en_2025-06.zim\",\n              \"size_mb\": 78\n            },\n            {\n              \"id\": \"wwwnc.cdc.gov_en_all\",\n              \"version\": \"2024-11\",\n              \"title\": \"CDC Health Information\",\n              \"description\": \"Disease prevention, travel health, and outbreak information\",\n              \"url\": \"https://download.kiwix.org/zim/zimit/wwwnc.cdc.gov_en_all_2024-11.zim\",\n              \"size_mb\": 170\n            }\n          ]\n        },\n        {\n          \"name\": \"Standard\",\n          \"slug\": \"medicine-standard\",\n          \"description\": \"Comprehensive medical encyclopedia with detailed health information. Includes everything in Essential.\",\n          \"includesTier\": \"medicine-essential\",\n          \"resources\": [\n            {\n              \"id\": \"medlineplus.gov_en_all\",\n              \"version\": \"2025-01\",\n              \"title\": \"MedlinePlus\",\n              \"description\": \"NIH's consumer health encyclopedia - diseases, conditions, drugs, supplements\",\n              \"url\": \"https://download.kiwix.org/zim/zimit/medlineplus.gov_en_all_2025-01.zim\",\n              \"size_mb\": 1800\n            }\n          ]\n        },\n        {\n          \"name\": \"Comprehensive\",\n          \"slug\": \"medicine-comprehensive\",\n          \"description\": \"Professional-level medical references and textbooks. Includes everything in Standard.\",\n          \"includesTier\": \"medicine-standard\",\n          \"resources\": [\n            {\n              \"id\": \"wikipedia_en_medicine_maxi\",\n              \"version\": \"2026-01\",\n              \"title\": \"Wikipedia Medicine\",\n              \"description\": \"Curated medical articles from Wikipedia with images\",\n              \"url\": \"https://download.kiwix.org/zim/wikipedia/wikipedia_en_medicine_maxi_2026-01.zim\",\n              \"size_mb\": 2000\n            },\n            {\n              \"id\": \"libretexts.org_en_med\",\n              \"version\": \"2025-01\",\n              \"title\": \"LibreTexts Medicine\",\n              \"description\": \"Open-source medical textbooks and educational content\",\n              \"url\": \"https://download.kiwix.org/zim/libretexts/libretexts.org_en_med_2025-01.zim\",\n              \"size_mb\": 1100\n            },\n            {\n              \"id\": \"librepathology_en_all_maxi\",\n              \"version\": \"2025-09\",\n              \"title\": \"LibrePathology\",\n              \"description\": \"Pathology reference for disease identification\",\n              \"url\": \"https://download.kiwix.org/zim/other/librepathology_en_all_maxi_2025-09.zim\",\n              \"size_mb\": 76\n            }\n          ]\n        }\n      ]\n    },\n    {\n      \"name\": \"Survival & Preparedness\",\n      \"slug\": \"survival\",\n      \"icon\": \"IconShieldCheck\",\n      \"description\": \"Emergency preparedness, bug-out planning, and tactical survival skills for crisis situations.\",\n      \"language\": \"en\",\n      \"tiers\": [\n        {\n          \"name\": \"Essential\",\n          \"slug\": \"survival-essential\",\n          \"description\": \"Core survival concepts and winter preparedness videos. Start here for emergency basics.\",\n          \"recommended\": true,\n          \"resources\": [\n            {\n              \"id\": \"canadian_prepper_winterprepping_en\",\n              \"version\": \"2025-11\",\n              \"title\": \"Canadian Prepper: Winter Prepping\",\n              \"description\": \"Video guides for winter survival and cold weather emergencies\",\n              \"url\": \"https://download.kiwix.org/zim/videos/canadian_prepper_winterprepping_en_2025-11.zim\",\n              \"size_mb\": 1340\n            },\n            {\n              \"id\": \"canadian_prepper_bugoutroll_en\",\n              \"version\": \"2025-08\",\n              \"title\": \"Canadian Prepper: Bug Out Roll\",\n              \"description\": \"Essential gear selection for your bug-out bag\",\n              \"url\": \"https://download.kiwix.org/zim/videos/canadian_prepper_bugoutroll_en_2025-08.zim\",\n              \"size_mb\": 975\n            }\n          ]\n        },\n        {\n          \"name\": \"Standard\",\n          \"slug\": \"survival-standard\",\n          \"description\": \"Bug-out strategies and urban survival techniques. Includes everything in Essential.\",\n          \"includesTier\": \"survival-essential\",\n          \"resources\": [\n            {\n              \"id\": \"canadian_prepper_bugoutconcepts_en\",\n              \"version\": \"2025-11\",\n              \"title\": \"Canadian Prepper: Bug Out Concepts\",\n              \"description\": \"Strategies and planning for emergency evacuation\",\n              \"url\": \"https://download.kiwix.org/zim/videos/canadian_prepper_bugoutconcepts_en_2025-11.zim\",\n              \"size_mb\": 2890\n            },\n            {\n              \"id\": \"urban-prepper_en_all\",\n              \"version\": \"2025-11\",\n              \"title\": \"Urban Prepper\",\n              \"description\": \"Comprehensive urban emergency preparedness video series\",\n              \"url\": \"https://download.kiwix.org/zim/videos/urban-prepper_en_all_2025-11.zim\",\n              \"size_mb\": 2240\n            }\n          ]\n        },\n        {\n          \"name\": \"Comprehensive\",\n          \"slug\": \"survival-comprehensive\",\n          \"description\": \"Complete prepper library with food storage strategies and classic military strategy. Includes everything in Standard.\",\n          \"includesTier\": \"survival-standard\",\n          \"resources\": [\n            {\n              \"id\": \"canadian_prepper_preppingfood_en\",\n              \"version\": \"2025-09\",\n              \"title\": \"Canadian Prepper: Prepping Food\",\n              \"description\": \"Long-term food storage and survival meal preparation\",\n              \"url\": \"https://download.kiwix.org/zim/videos/canadian_prepper_preppingfood_en_2025-09.zim\",\n              \"size_mb\": 2160\n            },\n            {\n              \"id\": \"gutenberg_en_lcc-u\",\n              \"version\": \"2026-03\",\n              \"title\": \"Project Gutenberg: Military Science\",\n              \"description\": \"Classic military strategy, tactics, and field manuals\",\n              \"url\": \"https://download.kiwix.org/zim/gutenberg/gutenberg_en_lcc-u_2026-03.zim\",\n              \"size_mb\": 1200\n            }\n          ]\n        }\n      ]\n    },\n    {\n      \"name\": \"Education & Reference\",\n      \"slug\": \"education\",\n      \"icon\": \"IconSchool\",\n      \"description\": \"Encyclopedias, textbooks, tutorials, and educational videos for self-directed learning.\",\n      \"language\": \"en\",\n      \"tiers\": [\n        {\n          \"name\": \"Essential\",\n          \"slug\": \"education-essential\",\n          \"description\": \"Core reference materials - open textbooks and essential educational content. Lightweight, text-focused.\",\n          \"recommended\": true,\n          \"resources\": [\n            {\n              \"id\": \"wikibooks_en_all_nopic\",\n              \"version\": \"2025-10\",\n              \"title\": \"Wikibooks\",\n              \"description\": \"Open-content textbooks covering math, science, computing, and more\",\n              \"url\": \"https://download.kiwix.org/zim/wikibooks/wikibooks_en_all_nopic_2025-10.zim\",\n              \"size_mb\": 3100\n            }\n          ]\n        },\n        {\n          \"name\": \"Standard\",\n          \"slug\": \"education-standard\",\n          \"description\": \"Adds educational videos, university-level tutorials, and STEM textbooks. Includes Essential.\",\n          \"includesTier\": \"education-essential\",\n          \"resources\": [\n            {\n              \"id\": \"ted_mul_ted-ed\",\n              \"version\": \"2025-07\",\n              \"title\": \"TED-Ed\",\n              \"description\": \"Educational video lessons on science, history, literature, and more\",\n              \"url\": \"https://download.kiwix.org/zim/ted/ted_mul_ted-ed_2025-07.zim\",\n              \"size_mb\": 5610\n            },\n            {\n              \"id\": \"wikiversity_en_all_maxi\",\n              \"version\": \"2025-11\",\n              \"title\": \"Wikiversity\",\n              \"description\": \"Tutorials, courses, and learning materials for all levels\",\n              \"url\": \"https://download.kiwix.org/zim/wikiversity/wikiversity_en_all_maxi_2025-11.zim\",\n              \"size_mb\": 2370\n            },\n            {\n              \"id\": \"libretexts.org_en_math\",\n              \"version\": \"2025-01\",\n              \"title\": \"LibreTexts Mathematics\",\n              \"description\": \"Open-source math textbooks from algebra to calculus\",\n              \"url\": \"https://download.kiwix.org/zim/libretexts/libretexts.org_en_math_2025-01.zim\",\n              \"size_mb\": 831\n            },\n            {\n              \"id\": \"libretexts.org_en_phys\",\n              \"version\": \"2025-01\",\n              \"title\": \"LibreTexts Physics\",\n              \"description\": \"Physics courses and textbooks\",\n              \"url\": \"https://download.kiwix.org/zim/libretexts/libretexts.org_en_phys_2025-01.zim\",\n              \"size_mb\": 560\n            },\n            {\n              \"id\": \"libretexts.org_en_chem\",\n              \"version\": \"2025-01\",\n              \"title\": \"LibreTexts Chemistry\",\n              \"description\": \"Chemistry courses and textbooks\",\n              \"url\": \"https://download.kiwix.org/zim/libretexts/libretexts.org_en_chem_2025-01.zim\",\n              \"size_mb\": 2180\n            },\n            {\n              \"id\": \"libretexts.org_en_bio\",\n              \"version\": \"2025-01\",\n              \"title\": \"LibreTexts Biology\",\n              \"description\": \"Biology courses and textbooks\",\n              \"url\": \"https://download.kiwix.org/zim/libretexts/libretexts.org_en_bio_2025-01.zim\",\n              \"size_mb\": 2240\n            }\n          ]\n        },\n        {\n          \"name\": \"Comprehensive\",\n          \"slug\": \"education-comprehensive\",\n          \"description\": \"Complete educational library with enhanced textbooks and TED talks. Includes Standard.\",\n          \"includesTier\": \"education-standard\",\n          \"resources\": [\n            {\n              \"id\": \"wikibooks_en_all_maxi\",\n              \"version\": \"2025-10\",\n              \"title\": \"Wikibooks (With Images)\",\n              \"description\": \"Open textbooks with full illustrations and diagrams\",\n              \"url\": \"https://download.kiwix.org/zim/wikibooks/wikibooks_en_all_maxi_2025-10.zim\",\n              \"size_mb\": 5400\n            },\n            {\n              \"id\": \"ted_mul_ted-conference\",\n              \"version\": \"2025-08\",\n              \"title\": \"TED Conference\",\n              \"description\": \"Main TED conference talks on ideas worth spreading\",\n              \"url\": \"https://download.kiwix.org/zim/ted/ted_mul_ted-conference_2025-08.zim\",\n              \"size_mb\": 16500\n            },\n            {\n              \"id\": \"libretexts.org_en_human\",\n              \"version\": \"2025-01\",\n              \"title\": \"LibreTexts Humanities\",\n              \"description\": \"Literature, philosophy, history, and social sciences\",\n              \"url\": \"https://download.kiwix.org/zim/libretexts/libretexts.org_en_human_2025-01.zim\",\n              \"size_mb\": 3730\n            },\n            {\n              \"id\": \"libretexts.org_en_geo\",\n              \"version\": \"2025-01\",\n              \"title\": \"LibreTexts Geosciences\",\n              \"description\": \"Earth science, geology, and environmental studies\",\n              \"url\": \"https://download.kiwix.org/zim/libretexts/libretexts.org_en_geo_2025-01.zim\",\n              \"size_mb\": 1190\n            },\n            {\n              \"id\": \"libretexts.org_en_eng\",\n              \"version\": \"2025-01\",\n              \"title\": \"LibreTexts Engineering\",\n              \"description\": \"Engineering courses and technical references\",\n              \"url\": \"https://download.kiwix.org/zim/libretexts/libretexts.org_en_eng_2025-01.zim\",\n              \"size_mb\": 678\n            },\n            {\n              \"id\": \"libretexts.org_en_biz\",\n              \"version\": \"2025-01\",\n              \"title\": \"LibreTexts Business\",\n              \"description\": \"Business, economics, and management textbooks\",\n              \"url\": \"https://download.kiwix.org/zim/libretexts/libretexts.org_en_biz_2025-01.zim\",\n              \"size_mb\": 840\n            }\n          ]\n        }\n      ]\n    },\n    {\n      \"name\": \"DIY & Repair\",\n      \"slug\": \"diy\",\n      \"icon\": \"IconTool\",\n      \"description\": \"Fix it yourself - repair guides, home improvement, woodworking, and vehicle maintenance.\",\n      \"language\": \"en\",\n      \"tiers\": [\n        {\n          \"name\": \"Essential\",\n          \"slug\": \"diy-essential\",\n          \"description\": \"Core repair knowledge - woodworking basics and vehicle maintenance Q&A.\",\n          \"recommended\": true,\n          \"resources\": [\n            {\n              \"id\": \"woodworking.stackexchange.com_en_all\",\n              \"version\": \"2025-12\",\n              \"title\": \"Woodworking Q&A\",\n              \"description\": \"Stack Exchange Q&A for carpentry, joinery, and woodcraft\",\n              \"url\": \"https://download.kiwix.org/zim/stack_exchange/woodworking.stackexchange.com_en_all_2025-12.zim\",\n              \"size_mb\": 99\n            },\n            {\n              \"id\": \"mechanics.stackexchange.com_en_all\",\n              \"version\": \"2025-12\",\n              \"title\": \"Motor Vehicle Maintenance Q&A\",\n              \"description\": \"Stack Exchange Q&A for car and motorcycle repair\",\n              \"url\": \"https://download.kiwix.org/zim/stack_exchange/mechanics.stackexchange.com_en_all_2025-12.zim\",\n              \"size_mb\": 321\n            }\n          ]\n        },\n        {\n          \"name\": \"Standard\",\n          \"slug\": \"diy-standard\",\n          \"description\": \"Home improvement expertise with thousands of Q&A threads. Includes Essential.\",\n          \"includesTier\": \"diy-essential\",\n          \"resources\": [\n            {\n              \"id\": \"diy.stackexchange.com_en_all\",\n              \"version\": \"2025-12\",\n              \"title\": \"DIY & Home Improvement Q&A\",\n              \"description\": \"Stack Exchange Q&A for home repairs, electrical, plumbing, and construction\",\n              \"url\": \"https://download.kiwix.org/zim/stack_exchange/diy.stackexchange.com_en_all_2025-12.zim\",\n              \"size_mb\": 1900\n            }\n          ]\n        },\n        {\n          \"name\": \"Comprehensive\",\n          \"slug\": \"diy-comprehensive\",\n          \"description\": \"Complete repair library with step-by-step guides for electronics and appliances. Includes Standard.\",\n          \"includesTier\": \"diy-standard\",\n          \"resources\": [\n            {\n              \"id\": \"ifixit_en_all\",\n              \"version\": \"2025-12\",\n              \"title\": \"iFixit Repair Guides\",\n              \"description\": \"Step-by-step repair guides for electronics, appliances, and vehicles\",\n              \"url\": \"https://download.kiwix.org/zim/ifixit/ifixit_en_all_2025-12.zim\",\n              \"size_mb\": 3570\n            }\n          ]\n        }\n      ]\n    },\n    {\n      \"name\": \"Agriculture & Food\",\n      \"slug\": \"agriculture\",\n      \"icon\": \"IconPlant\",\n      \"description\": \"Grow and prepare your own food - gardening, cooking techniques, and food preservation.\",\n      \"language\": \"en\",\n      \"tiers\": [\n        {\n          \"name\": \"Essential\",\n          \"slug\": \"agriculture-essential\",\n          \"description\": \"Quick recipes and cooking basics for everyday meal preparation.\",\n          \"recommended\": true,\n          \"resources\": [\n            {\n              \"id\": \"foss.cooking_en_all\",\n              \"version\": \"2025-11\",\n              \"title\": \"FOSS Cooking\",\n              \"description\": \"Quick and easy cooking guides and recipes\",\n              \"url\": \"https://download.kiwix.org/zim/zimit/foss.cooking_en_all_2025-11.zim\",\n              \"size_mb\": 24\n            },\n            {\n              \"id\": \"based.cooking_en_all\",\n              \"version\": \"2025-11\",\n              \"title\": \"Based.Cooking\",\n              \"description\": \"Simple, practical recipes from the community\",\n              \"url\": \"https://download.kiwix.org/zim/zimit/based.cooking_en_all_2025-11.zim\",\n              \"size_mb\": 16\n            }\n          ]\n        },\n        {\n          \"name\": \"Standard\",\n          \"slug\": \"agriculture-standard\",\n          \"description\": \"Gardening knowledge and cooking expertise from community Q&A. Includes Essential.\",\n          \"includesTier\": \"agriculture-essential\",\n          \"resources\": [\n            {\n              \"id\": \"gardening.stackexchange.com_en_all\",\n              \"version\": \"2025-12\",\n              \"title\": \"Gardening Q&A\",\n              \"description\": \"Stack Exchange Q&A for growing your own food, plant care, and landscaping\",\n              \"url\": \"https://download.kiwix.org/zim/stack_exchange/gardening.stackexchange.com_en_all_2025-12.zim\",\n              \"size_mb\": 923\n            },\n            {\n              \"id\": \"cooking.stackexchange.com_en_all\",\n              \"version\": \"2025-12\",\n              \"title\": \"Cooking Q&A\",\n              \"description\": \"Stack Exchange Q&A for cooking techniques, food safety, and recipes\",\n              \"url\": \"https://download.kiwix.org/zim/stack_exchange/cooking.stackexchange.com_en_all_2025-12.zim\",\n              \"size_mb\": 236\n            },\n            {\n              \"id\": \"zimgit-food-preparation_en\",\n              \"version\": \"2025-04\",\n              \"title\": \"Food for Preppers\",\n              \"description\": \"Recipes and techniques for food preservation and long-term storage\",\n              \"url\": \"https://download.kiwix.org/zim/other/zimgit-food-preparation_en_2025-04.zim\",\n              \"size_mb\": 98\n            }\n          ]\n        },\n        {\n          \"name\": \"Comprehensive\",\n          \"slug\": \"agriculture-comprehensive\",\n          \"description\": \"Complete self-sufficiency with homesteading videos, classic agricultural texts, and advanced techniques. Includes Standard.\",\n          \"includesTier\": \"agriculture-standard\",\n          \"resources\": [\n            {\n              \"id\": \"lrnselfreliance_en_all\",\n              \"version\": \"2025-12\",\n              \"title\": \"Learning Self-Reliance: Homesteading\",\n              \"description\": \"Beekeeping, animal husbandry, and sustainable living practices\",\n              \"url\": \"https://download.kiwix.org/zim/videos/lrnselfreliance_en_all_2025-12.zim\",\n              \"size_mb\": 3970\n            },\n            {\n              \"id\": \"gutenberg_en_lcc-s\",\n              \"version\": \"2026-03\",\n              \"title\": \"Project Gutenberg: Agriculture\",\n              \"description\": \"Classic texts on farming, animal husbandry, plant cultivation, and food preservation\",\n              \"url\": \"https://download.kiwix.org/zim/gutenberg/gutenberg_en_lcc-s_2026-03.zim\",\n              \"size_mb\": 4300\n            }\n          ]\n        }\n      ]\n    },\n    {\n      \"name\": \"Computing & Technology\",\n      \"slug\": \"computing\",\n      \"icon\": \"IconCode\",\n      \"description\": \"Programming tutorials, electronics projects, and technical documentation for makers and developers.\",\n      \"language\": \"en\",\n      \"tiers\": [\n        {\n          \"name\": \"Essential\",\n          \"slug\": \"computing-essential\",\n          \"description\": \"Learn to code with interactive tutorials and core programming documentation.\",\n          \"recommended\": true,\n          \"resources\": [\n            {\n              \"id\": \"freecodecamp_en_all\",\n              \"version\": \"2025-11\",\n              \"title\": \"freeCodeCamp\",\n              \"description\": \"Interactive programming tutorials - JavaScript, algorithms, and data structures\",\n              \"url\": \"https://download.kiwix.org/zim/freecodecamp/freecodecamp_en_all_2025-11.zim\",\n              \"size_mb\": 8\n            },\n            {\n              \"id\": \"devdocs_en_python\",\n              \"version\": \"2026-01\",\n              \"title\": \"Python Documentation\",\n              \"description\": \"Complete Python language reference and tutorials\",\n              \"url\": \"https://download.kiwix.org/zim/devdocs/devdocs_en_python_2026-01.zim\",\n              \"size_mb\": 4\n            },\n            {\n              \"id\": \"devdocs_en_javascript\",\n              \"version\": \"2026-01\",\n              \"title\": \"JavaScript Documentation\",\n              \"description\": \"MDN JavaScript reference and guides\",\n              \"url\": \"https://download.kiwix.org/zim/devdocs/devdocs_en_javascript_2026-01.zim\",\n              \"size_mb\": 3\n            },\n            {\n              \"id\": \"devdocs_en_html\",\n              \"version\": \"2026-01\",\n              \"title\": \"HTML Documentation\",\n              \"description\": \"MDN HTML elements and attributes reference\",\n              \"url\": \"https://download.kiwix.org/zim/devdocs/devdocs_en_html_2026-01.zim\",\n              \"size_mb\": 2\n            },\n            {\n              \"id\": \"devdocs_en_css\",\n              \"version\": \"2026-01\",\n              \"title\": \"CSS Documentation\",\n              \"description\": \"MDN CSS properties and selectors reference\",\n              \"url\": \"https://download.kiwix.org/zim/devdocs/devdocs_en_css_2026-01.zim\",\n              \"size_mb\": 5\n            }\n          ]\n        },\n        {\n          \"name\": \"Standard\",\n          \"slug\": \"computing-standard\",\n          \"description\": \"Maker projects with Arduino and Raspberry Pi, plus more programming docs. Includes Essential.\",\n          \"includesTier\": \"computing-essential\",\n          \"resources\": [\n            {\n              \"id\": \"arduino.stackexchange.com_en_all\",\n              \"version\": \"2025-12\",\n              \"title\": \"Arduino Q&A\",\n              \"description\": \"Stack Exchange Q&A for Arduino microcontroller projects\",\n              \"url\": \"https://download.kiwix.org/zim/stack_exchange/arduino.stackexchange.com_en_all_2025-12.zim\",\n              \"size_mb\": 247\n            },\n            {\n              \"id\": \"raspberrypi.stackexchange.com_en_all\",\n              \"version\": \"2025-12\",\n              \"title\": \"Raspberry Pi Q&A\",\n              \"description\": \"Stack Exchange Q&A for Raspberry Pi projects and troubleshooting\",\n              \"url\": \"https://download.kiwix.org/zim/stack_exchange/raspberrypi.stackexchange.com_en_all_2025-12.zim\",\n              \"size_mb\": 285\n            },\n            {\n              \"id\": \"devdocs_en_node\",\n              \"version\": \"2026-01\",\n              \"title\": \"Node.js Documentation\",\n              \"description\": \"Node.js API reference and guides\",\n              \"url\": \"https://download.kiwix.org/zim/devdocs/devdocs_en_node_2026-01.zim\",\n              \"size_mb\": 1\n            },\n            {\n              \"id\": \"devdocs_en_react\",\n              \"version\": \"2026-02\",\n              \"title\": \"React Documentation\",\n              \"description\": \"React library reference and tutorials\",\n              \"url\": \"https://download.kiwix.org/zim/devdocs/devdocs_en_react_2026-02.zim\",\n              \"size_mb\": 3\n            },\n            {\n              \"id\": \"devdocs_en_git\",\n              \"version\": \"2026-01\",\n              \"title\": \"Git Documentation\",\n              \"description\": \"Git version control reference\",\n              \"url\": \"https://download.kiwix.org/zim/devdocs/devdocs_en_git_2026-01.zim\",\n              \"size_mb\": 1\n            }\n          ]\n        },\n        {\n          \"name\": \"Comprehensive\",\n          \"slug\": \"computing-comprehensive\",\n          \"description\": \"Deep electronics knowledge and extensive Q&A for hardware projects. Includes Standard.\",\n          \"includesTier\": \"computing-standard\",\n          \"resources\": [\n            {\n              \"id\": \"electronics.stackexchange.com_en_all\",\n              \"version\": \"2025-12\",\n              \"title\": \"Electronics Q&A\",\n              \"description\": \"Stack Exchange Q&A for circuit design, components, and electrical engineering\",\n              \"url\": \"https://download.kiwix.org/zim/stack_exchange/electronics.stackexchange.com_en_all_2025-12.zim\",\n              \"size_mb\": 3800\n            },\n            {\n              \"id\": \"robotics.stackexchange.com_en_all\",\n              \"version\": \"2025-12\",\n              \"title\": \"Robotics Q&A\",\n              \"description\": \"Stack Exchange Q&A for robotics projects and automation\",\n              \"url\": \"https://download.kiwix.org/zim/stack_exchange/robotics.stackexchange.com_en_all_2025-12.zim\",\n              \"size_mb\": 233\n            },\n            {\n              \"id\": \"devdocs_en_docker\",\n              \"version\": \"2026-01\",\n              \"title\": \"Docker Documentation\",\n              \"description\": \"Docker container reference and guides\",\n              \"url\": \"https://download.kiwix.org/zim/devdocs/devdocs_en_docker_2026-01.zim\",\n              \"size_mb\": 2\n            },\n            {\n              \"id\": \"devdocs_en_bash\",\n              \"version\": \"2026-01\",\n              \"title\": \"Linux Documentation\",\n              \"description\": \"Linux command reference and system administration\",\n              \"url\": \"https://download.kiwix.org/zim/devdocs/devdocs_en_bash_2026-01.zim\",\n              \"size_mb\": 1\n            }\n          ]\n        }\n      ]\n    }\n  ]\n}\n"
  },
  {
    "path": "collections/maps.json",
    "content": "{\n  \"spec_version\": \"2026-02-11\",\n  \"collections\": [\n    {\n      \"name\": \"Pacific Region\",\n      \"slug\": \"pacific\",\n      \"description\": \"Map assets for the Pacific region, including Alaska, California, Hawaii, Oregon, and Washington.\",\n      \"icon\": \"IconMap\",\n      \"language\": \"en\",\n      \"resources\": [\n        {\n          \"id\": \"alaska\",\n          \"version\": \"2025-12\",\n          \"title\": \"Alaska\",\n          \"description\": \"Topographic maps for the state of Alaska.\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/alaska_2025-12.pmtiles\",\n          \"size_mb\": 684\n        },\n        {\n          \"id\": \"california\",\n          \"version\": \"2025-12\",\n          \"title\": \"California\",\n          \"description\": \"Topographic maps for the state of California.\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/california_2025-12.pmtiles\",\n          \"size_mb\": 1100\n        },\n        {\n          \"id\": \"hawaii\",\n          \"version\": \"2025-12\",\n          \"title\": \"Hawaii\",\n          \"description\": \"Topographic maps for the state of Hawaii.\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/hawaii_2025-12.pmtiles\",\n          \"size_mb\": 28\n        },\n        {\n          \"id\": \"oregon\",\n          \"version\": \"2025-12\",\n          \"title\": \"Oregon\",\n          \"description\": \"Topographic maps for the state of Oregon.\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/oregon_2025-12.pmtiles\",\n          \"size_mb\": 379\n        },\n        {\n          \"id\": \"washington\",\n          \"version\": \"2025-12\",\n          \"title\": \"Washington\",\n          \"description\": \"Topographic maps for the state of Washington.\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/washington_2025-12.pmtiles\",\n          \"size_mb\": 466\n        }\n      ]\n    },\n    {\n      \"name\": \"Mountain Region\",\n      \"slug\": \"mountain\",\n      \"description\": \"Map assets for the Mountain region, including Arizona, Colorado, Idaho, Montana, Nevada, New Mexico, Utah, and Wyoming.\",\n      \"icon\": \"IconMap\",\n      \"language\": \"en\",\n      \"resources\": [\n        {\n          \"id\": \"arizona\",\n          \"version\": \"2025-12\",\n          \"title\": \"Arizona\",\n          \"description\": \"Topographic maps for the state of Arizona.\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/arizona_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"colorado\",\n          \"version\": \"2025-12\",\n          \"title\": \"Colorado\",\n          \"description\": \"Topographic maps for the state of Colorado.\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/colorado_2025-12.pmtiles\",\n          \"size_mb\": 450\n        },\n        {\n          \"id\": \"idaho\",\n          \"version\": \"2025-12\",\n          \"title\": \"Idaho\",\n          \"description\": \"Topographic maps for the state of Idaho.\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/idaho_2025-12.pmtiles\",\n          \"size_mb\": 220\n        },\n        {\n          \"id\": \"montana\",\n          \"version\": \"2025-12\",\n          \"title\": \"Montana\",\n          \"description\": \"Topographic maps for the state of Montana.\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/montana_2025-12.pmtiles\",\n          \"size_mb\": 270\n        },\n        {\n          \"id\": \"nevada\",\n          \"version\": \"2025-12\",\n          \"title\": \"Nevada\",\n          \"description\": \"Topographic maps for the state of Nevada.\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/nevada_2025-12.pmtiles\",\n          \"size_mb\": 200\n        },\n        {\n          \"id\": \"new_mexico\",\n          \"version\": \"2025-12\",\n          \"title\": \"New Mexico\",\n          \"description\": \"Topographic maps for the state of New Mexico.\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/new_mexico_2025-12.pmtiles\",\n          \"size_mb\": 230\n        },\n        {\n          \"id\": \"utah\",\n          \"version\": \"2025-12\",\n          \"title\": \"Utah\",\n          \"description\": \"Topographic maps for the state of Utah.\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/utah_2025-12.pmtiles\",\n          \"size_mb\": 240\n        },\n        {\n          \"id\": \"wyoming\",\n          \"version\": \"2025-12\",\n          \"title\": \"Wyoming\",\n          \"description\": \"Topographic maps for the state of Wyoming.\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/wyoming_2025-12.pmtiles\",\n          \"size_mb\": 210\n        }\n      ]\n    },\n    {\n      \"name\": \"West South Central\",\n      \"slug\": \"west-south-central\",\n      \"description\": \"Map assets for the West South Central region, including Arkansas, Louisiana, Oklahoma, and Texas.\",\n      \"icon\": \"IconMap\",\n      \"language\": \"en\",\n      \"resources\": [\n        {\n          \"id\": \"arkansas\",\n          \"version\": \"2025-12\",\n          \"title\": \"Arkansas\",\n          \"description\": \"Topographic maps for the state of Arkansas\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/arkansas_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"oklahoma\",\n          \"version\": \"2025-12\",\n          \"title\": \"Oklahoma\",\n          \"description\": \"Topographic maps for the state of Oklahoma\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/oklahoma_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"louisiana\",\n          \"version\": \"2025-12\",\n          \"title\": \"Louisiana\",\n          \"description\": \"Topographic maps for the state of Louisiana\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/louisiana_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"texas\",\n          \"version\": \"2025-12\",\n          \"title\": \"Texas\",\n          \"description\": \"Topographic maps for the state of Texas\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/texas_2025-12.pmtiles\",\n          \"size_mb\": 400\n        }\n      ]\n    },\n    {\n      \"name\": \"East South Central\",\n      \"slug\": \"east-south-central\",\n      \"description\": \"Map assets for the East South Central region, including Alabama, Kentucky, Mississippi, and Tennessee.\",\n      \"icon\": \"IconMap\",\n      \"language\": \"en\",\n      \"resources\": [\n        {\n          \"id\": \"alabama\",\n          \"version\": \"2025-12\",\n          \"title\": \"Alabama\",\n          \"description\": \"Topographic maps for the state of Alabama\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/alabama_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"kentucky\",\n          \"version\": \"2025-12\",\n          \"title\": \"Kentucky\",\n          \"description\": \"Topographic maps for the state of Kentucky\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/kentucky_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"mississippi\",\n          \"version\": \"2025-12\",\n          \"title\": \"Mississippi\",\n          \"description\": \"Topographic maps for the state of Mississippi\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/mississippi_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"tennessee\",\n          \"version\": \"2025-12\",\n          \"title\": \"Tennessee\",\n          \"description\": \"Topographic maps for the state of Tennessee\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/tennessee_2025-12.pmtiles\",\n          \"size_mb\": 400\n        }\n      ]\n    },\n    {\n      \"name\": \"South Atlantic\",\n      \"slug\": \"south-atlantic\",\n      \"description\": \"Map assets for the South Atlantic region, including Delaware, Florida, Georgia, Maryland, North Carolina, South Carolina, Virginia, and West Virginia.\",\n      \"icon\": \"IconMap\",\n      \"language\": \"en\",\n      \"resources\": [\n        {\n          \"id\": \"delaware\",\n          \"version\": \"2025-12\",\n          \"title\": \"Delaware\",\n          \"description\": \"Topographic maps for the state of Delaware\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/delaware_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"florida\",\n          \"version\": \"2025-12\",\n          \"title\": \"Florida\",\n          \"description\": \"Topographic maps for the state of Florida\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/florida_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"georgia\",\n          \"version\": \"2025-12\",\n          \"title\": \"Georgia\",\n          \"description\": \"Topographic maps for the state of Georgia\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/georgia_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"maryland\",\n          \"version\": \"2025-12\",\n          \"title\": \"Maryland\",\n          \"description\": \"Topographic maps for the state of Maryland\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/maryland_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"north_carolina\",\n          \"version\": \"2025-12\",\n          \"title\": \"North_Carolina\",\n          \"description\": \"Topographic maps for the state of North_Carolina\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/north_carolina_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"south_carolina\",\n          \"version\": \"2025-12\",\n          \"title\": \"South_Carolina\",\n          \"description\": \"Topographic maps for the state of South_Carolina\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/south_carolina_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"virginia\",\n          \"version\": \"2025-12\",\n          \"title\": \"Virginia\",\n          \"description\": \"Topographic maps for the state of Virginia\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/virginia_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"west_virginia\",\n          \"version\": \"2025-12\",\n          \"title\": \"West_Virginia\",\n          \"description\": \"Topographic maps for the state of West_Virginia\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/west_virginia_2025-12.pmtiles\",\n          \"size_mb\": 400\n        }\n      ]\n    },\n    {\n      \"name\": \"West North Central\",\n      \"slug\": \"west-north-central\",\n      \"description\": \"Map assets for the West North Central region, including Iowa, Kansas, Minnesota, Missouri, Nebraska, North Dakota, and South Dakota.\",\n      \"icon\": \"IconMap\",\n      \"language\": \"en\",\n      \"resources\": [\n        {\n          \"id\": \"iowa\",\n          \"version\": \"2025-12\",\n          \"title\": \"Iowa\",\n          \"description\": \"Topographic maps for the state of Iowa\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/iowa_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"kansas\",\n          \"version\": \"2025-12\",\n          \"title\": \"Kansas\",\n          \"description\": \"Topographic maps for the state of Kansas\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/kansas_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"minnesota\",\n          \"version\": \"2025-12\",\n          \"title\": \"Minnesota\",\n          \"description\": \"Topographic maps for the state of Minnesota\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/minnesota_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"missouri\",\n          \"version\": \"2025-12\",\n          \"title\": \"Missouri\",\n          \"description\": \"Topographic maps for the state of Missouri\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/missouri_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"nebraska\",\n          \"version\": \"2025-12\",\n          \"title\": \"Nebraska\",\n          \"description\": \"Topographic maps for the state of Nebraska\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/nebraska_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"north_dakota\",\n          \"version\": \"2025-12\",\n          \"title\": \"North_Dakota\",\n          \"description\": \"Topographic maps for the state of North_Dakota\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/north_dakota_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"south_dakota\",\n          \"version\": \"2025-12\",\n          \"title\": \"South_Dakota\",\n          \"description\": \"Topographic maps for the state of South_Dakota\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/south_dakota_2025-12.pmtiles\",\n          \"size_mb\": 400\n        }\n      ]\n    },\n    {\n      \"name\": \"East North Central\",\n      \"slug\": \"east-north-central\",\n      \"description\": \"Map assets for the East North Central region, including Illinois, Indiana, Michigan, Ohio, and Wisconsin.\",\n      \"icon\": \"IconMap\",\n      \"language\": \"en\",\n      \"resources\": [\n        {\n          \"id\": \"illinois\",\n          \"version\": \"2025-12\",\n          \"title\": \"Illinois\",\n          \"description\": \"Topographic maps for the state of Illinois\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/illinois_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"indiana\",\n          \"version\": \"2025-12\",\n          \"title\": \"Indiana\",\n          \"description\": \"Topographic maps for the state of Indiana\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/indiana_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"michigan\",\n          \"version\": \"2025-12\",\n          \"title\": \"Michigan\",\n          \"description\": \"Topographic maps for the state of Michigan\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/michigan_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"ohio\",\n          \"version\": \"2025-12\",\n          \"title\": \"Ohio\",\n          \"description\": \"Topographic maps for the state of Ohio\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/ohio_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"wisconsin\",\n          \"version\": \"2025-12\",\n          \"title\": \"Wisconsin\",\n          \"description\": \"Topographic maps for the state of Wisconsin\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/wisconsin_2025-12.pmtiles\",\n          \"size_mb\": 400\n        }\n      ]\n    },\n    {\n      \"name\": \"Mid-Atlantic\",\n      \"slug\": \"mid-atlantic\",\n      \"description\": \"Map assets for the Mid-Atlantic region, including New Jersey, New York, and Pennsylvania.\",\n      \"icon\": \"IconMap\",\n      \"language\": \"en\",\n      \"resources\": [\n        {\n          \"id\": \"new_jersey\",\n          \"version\": \"2025-12\",\n          \"title\": \"New_Jersey\",\n          \"description\": \"Topographic maps for the state of New_Jersey\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/new_jersey_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"new_york\",\n          \"version\": \"2025-12\",\n          \"title\": \"New_York\",\n          \"description\": \"Topographic maps for the state of New_York\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/new_york_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"pennsylvania\",\n          \"version\": \"2025-12\",\n          \"title\": \"Pennsylvania\",\n          \"description\": \"Topographic maps for the state of Pennsylvania\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/pennsylvania_2025-12.pmtiles\",\n          \"size_mb\": 400\n        }\n      ]\n    },\n    {\n      \"name\": \"New England\",\n      \"slug\": \"new-england\",\n      \"description\": \"Map assets for the New England region, including Connecticut, Maine, Massachusetts, New Hampshire, Rhode Island, and Vermont.\",\n      \"icon\": \"IconMap\",\n      \"language\": \"en\",\n      \"resources\": [\n        {\n          \"id\": \"connecticut\",\n          \"version\": \"2025-12\",\n          \"title\": \"Connecticut\",\n          \"description\": \"Topographic maps for the state of Connecticut\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/connecticut_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"maine\",\n          \"version\": \"2025-12\",\n          \"title\": \"Maine\",\n          \"description\": \"Topographic maps for the state of Maine\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/maine_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"massachusetts\",\n          \"version\": \"2025-12\",\n          \"title\": \"Massachusetts\",\n          \"description\": \"Topographic maps for the state of Massachusetts\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/massachusetts_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"new_hampshire\",\n          \"version\": \"2025-12\",\n          \"title\": \"New_Hampshire\",\n          \"description\": \"Topographic maps for the state of New_Hampshire\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/new_hampshire_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"rhode_island\",\n          \"version\": \"2025-12\",\n          \"title\": \"Rhode_Island\",\n          \"description\": \"Topographic maps for the state of Rhode_Island\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/rhode_island_2025-12.pmtiles\",\n          \"size_mb\": 400\n        },\n        {\n          \"id\": \"vermont\",\n          \"version\": \"2025-12\",\n          \"title\": \"Vermont\",\n          \"description\": \"Topographic maps for the state of Vermont\",\n          \"url\": \"https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/vermont_2025-12.pmtiles\",\n          \"size_mb\": 400\n        }\n      ]\n    }\n  ]\n}\n"
  },
  {
    "path": "collections/wikipedia.json",
    "content": "{\n  \"spec_version\": \"2026-03-23\",\n  \"options\": [\n    {\n      \"id\": \"none\",\n      \"name\": \"No Wikipedia\",\n      \"description\": \"Skip Wikipedia installation\",\n      \"size_mb\": 0,\n      \"url\": null,\n      \"version\": null\n    },\n    {\n      \"id\": \"top-mini\",\n      \"name\": \"Quick Reference\",\n      \"description\": \"Top 100,000 articles with minimal images. Great for quick lookups.\",\n      \"size_mb\": 313,\n      \"url\": \"https://download.kiwix.org/zim/wikipedia/wikipedia_en_top_mini_2025-12.zim\",\n      \"version\": \"2025-12\"\n    },\n    {\n      \"id\": \"top-nopic\",\n      \"name\": \"Popular Articles\",\n      \"description\": \"Top articles without images. Good balance of content and size.\",\n      \"size_mb\": 2100,\n      \"url\": \"https://download.kiwix.org/zim/wikipedia/wikipedia_en_top_nopic_2025-12.zim\",\n      \"version\": \"2025-12\"\n    },\n    {\n      \"id\": \"all-mini\",\n      \"name\": \"Complete Wikipedia (Compact)\",\n      \"description\": \"All 6+ million articles in condensed format.\",\n      \"size_mb\": 11400,\n      \"url\": \"https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_mini_2025-12.zim\",\n      \"version\": \"2025-12\"\n    },\n    {\n      \"id\": \"all-nopic\",\n      \"name\": \"Complete Wikipedia (No Images)\",\n      \"description\": \"All articles without images. Comprehensive offline reference.\",\n      \"size_mb\": 25000,\n      \"url\": \"https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_nopic_2025-12.zim\",\n      \"version\": \"2025-12\"\n    },\n    {\n      \"id\": \"all-maxi\",\n      \"name\": \"Complete Wikipedia (Full)\",\n      \"description\": \"The complete experience with all images and media.\",\n      \"size_mb\": 115000,\n      \"url\": \"https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_maxi_2026-02.zim\",\n      \"version\": \"2026-02\"\n    }\n  ]\n}\n"
  },
  {
    "path": "install/collect_disk_info.sh",
    "content": "#!/bin/bash\n\nwhile true; do\n    DISK_LAYOUT=$(lsblk --json -o NAME,SIZE,TYPE,MODEL,SERIAL,VENDOR,ROTA,TRAN)\n\n    # Get filesystem usage excluding pseudo filesystems\n    FS_SIZE=$(df -B1 -x tmpfs -x devtmpfs -x squashfs | tail -n +2 | \\\n    awk 'BEGIN {print \"[\"} \n        {\n            if (NR > 1) printf \",\"\n            gsub(/%/, \"\", $5)\n            printf \"{\\\"fs\\\":\\\"%s\\\",\\\"size\\\":%s,\\\"used\\\":%s,\\\"available\\\":%s,\\\"use\\\":%s,\\\"mount\\\":\\\"%s\\\"}\", \n                    $1, $2, $3, $4, $5, $6\n        } \n        END {print \"]\"}')\n\n    cat > /tmp/nomad-disk-info.json << EOF\n{\n\"diskLayout\": $DISK_LAYOUT,\n\"fsSize\": $FS_SIZE\n}\nEOF\n\n    sleep 300\ndone"
  },
  {
    "path": "install/entrypoint.sh",
    "content": "#!/bin/sh\n\nset -e\n\necho \"Starting entrypoint script...\"\n\n# Ensure required storage directories exist (volume may be freshly mounted)\nmkdir -p /app/storage/logs /app/storage/kb_uploads\n\n# Run AdonisJS migrations\necho \"Running AdonisJS migrations...\"\nnode ace migration:run --force\n\n# Seed the database if needed\necho \"Seeding the database...\"\nnode ace db:seed\n\n# Start background workers for all queues\necho \"Starting background workers for all queues...\"\nnode ace queue:work --all &\n\n# Start the AdonisJS application\necho \"Starting AdonisJS application...\"\nexec node bin/server.js"
  },
  {
    "path": "install/install_nomad.sh",
    "content": "#!/bin/bash\n\n# Project N.O.M.A.D. Installation Script\n\n###################################################################################################################################################################################################\n\n# Script                | Project N.O.M.A.D. Installation Script\n# Version               | 1.0.0\n# Author                | Crosstalk Solutions, LLC\n# Website               | https://crosstalksolutions.com\n\n###################################################################################################################################################################################################\n#                                                                                                                                                                                                 #\n#                                                                                           Color Codes                                                                                           #\n#                                                                                                                                                                                                 #\n###################################################################################################################################################################################################\n\nRESET='\\033[0m'\nYELLOW='\\033[1;33m'\nWHITE_R='\\033[39m' # Same as GRAY_R for terminals with white background.\nGRAY_R='\\033[39m'\nRED='\\033[1;31m' # Light Red.\nGREEN='\\033[1;32m' # Light Green.\n\n###################################################################################################################################################################################################\n#                                                                                                                                                                                                 #\n#                                                                                  Constants & Variables                                                                                          #\n#                                                                                                                                                                                                 #\n###################################################################################################################################################################################################\n\nWHIPTAIL_TITLE=\"Project N.O.M.A.D Installation\"\nNOMAD_DIR=\"/opt/project-nomad\"\nMANAGEMENT_COMPOSE_FILE_URL=\"https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/management_compose.yaml\"\nSIDECAR_UPDATER_DOCKERFILE_URL=\"https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/sidecar-updater/Dockerfile\"\nSIDECAR_UPDATER_SCRIPT_URL=\"https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/sidecar-updater/update-watcher.sh\"\nSTART_SCRIPT_URL=\"https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/start_nomad.sh\"\nSTOP_SCRIPT_URL=\"https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/stop_nomad.sh\"\nUPDATE_SCRIPT_URL=\"https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/update_nomad.sh\"\nscript_option_debug='true'\naccepted_terms='false'\nlocal_ip_address=''\n\n###################################################################################################################################################################################################\n#                                                                                                                                                                                                 #\n#                                                                                           Functions                                                                                             #\n#                                                                                                                                                                                                 #\n###################################################################################################################################################################################################\n\nheader() {\n  if [[ \"${script_option_debug}\" != 'true' ]]; then clear; clear; fi\n  echo -e \"${GREEN}#########################################################################${RESET}\\\\n\"\n}\n\nheader_red() {\n  if [[ \"${script_option_debug}\" != 'true' ]]; then clear; clear; fi\n  echo -e \"${RED}#########################################################################${RESET}\\\\n\"\n}\n\ncheck_has_sudo() {\n  if sudo -n true 2>/dev/null; then\n    echo -e \"${GREEN}#${RESET} User has sudo permissions.\\\\n\"\n  else\n    echo \"User does not have sudo permissions\"\n    header_red\n    echo -e \"${RED}#${RESET} This script requires sudo permissions to run. Please run the script with sudo.\\\\n\"\n    echo -e \"${RED}#${RESET} For example: sudo bash $(basename \"$0\")\"\n    exit 1\n  fi\n}\n\ncheck_is_bash() {\n  if [[ -z \"$BASH_VERSION\" ]]; then\n    header_red\n    echo -e \"${RED}#${RESET} This script requires bash to run. Please run the script using bash.\\\\n\"\n    echo -e \"${RED}#${RESET} For example: bash $(basename \"$0\")\"\n    exit 1\n  fi\n    echo -e \"${GREEN}#${RESET} This script is running in bash.\\\\n\"\n}\n\ncheck_is_debian_based() {\n  if [[ ! -f /etc/debian_version ]]; then\n    header_red\n    echo -e \"${RED}#${RESET} This script is designed to run on Debian-based systems only.\\\\n\"\n    echo -e \"${RED}#${RESET} Please run this script on a Debian-based system and try again.\"\n    exit 1\n  fi\n    echo -e \"${GREEN}#${RESET} This script is running on a Debian-based system.\\\\n\"\n}\n\nensure_dependencies_installed() {\n  local missing_deps=()\n\n  # Check for curl\n  if ! command -v curl &> /dev/null; then\n    missing_deps+=(\"curl\")\n  fi\n\n  # Check for whiptail (used for dialogs, though not currently active)\n  # if ! command -v whiptail &> /dev/null; then\n  #   missing_deps+=(\"whiptail\")\n  # fi\n\n  if [[ ${#missing_deps[@]} -gt 0 ]]; then\n    echo -e \"${YELLOW}#${RESET} Installing required dependencies: ${missing_deps[*]}...\\\\n\"\n    sudo apt-get update\n    sudo apt-get install -y \"${missing_deps[@]}\"\n\n    # Verify installation\n    for dep in \"${missing_deps[@]}\"; do\n      if ! command -v \"$dep\" &> /dev/null; then\n        echo -e \"${RED}#${RESET} Failed to install $dep. Please install it manually and try again.\"\n        exit 1\n      fi\n    done\n    echo -e \"${GREEN}#${RESET} Dependencies installed successfully.\\\\n\"\n  else\n    echo -e \"${GREEN}#${RESET} All required dependencies are already installed.\\\\n\"\n  fi\n}\n\ncheck_is_debug_mode(){\n  # Check if the script is being run in debug mode\n  if [[ \"${script_option_debug}\" == 'true' ]]; then\n    echo -e \"${YELLOW}#${RESET} Debug mode is enabled, the script will not clear the screen...\\\\n\"\n  else\n    clear; clear\n  fi\n}\n\ngenerateRandomPass() {\n  local length=\"${1:-32}\"  # Default to 32\n  local password\n  \n  # Generate random password using /dev/urandom\n  password=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c \"$length\")\n  \n  echo \"$password\"\n}\n\nensure_docker_installed() {\n  if ! command -v docker &> /dev/null; then\n    echo -e \"${YELLOW}#${RESET} Docker not found. Installing Docker...\\\\n\"\n    \n    # Update package database\n    sudo apt-get update\n    \n    # Install prerequisites\n    sudo apt-get install -y ca-certificates curl\n    \n    # Create directory for keyrings\n    # sudo install -m 0755 -d /etc/apt/keyrings\n    \n    # # Download Docker's official GPG key\n    # sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc\n    # sudo chmod a+r /etc/apt/keyrings/docker.asc\n\n    # # Add the repository to Apt sources\n    # echo \\\n    #   \"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \\\n    #   $(. /etc/os-release && echo \"$VERSION_CODENAME\") stable\" | \\\n    #   sudo tee /etc/apt/sources.list.d/docker.list > /dev/null\n\n    # # Update the package database with the Docker packages from the newly added repo\n    # sudo apt-get update\n\n    # # Install Docker packages\n    # sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin\n\n    # Download the Docker convenience script\n    curl -fsSL https://get.docker.com -o get-docker.sh\n\n    # Run the Docker installation script\n    sudo sh get-docker.sh\n\n    # Check if Docker was installed successfully\n    if ! command -v docker &> /dev/null; then\n      echo -e \"${RED}#${RESET} Docker installation failed. Please check the logs and try again.\"\n      exit 1\n    fi\n    \n    echo -e \"${GREEN}#${RESET} Docker installation completed.\\\\n\"\n  else\n    echo -e \"${GREEN}#${RESET} Docker is already installed.\\\\n\"\n    \n    # Check if Docker service is running\n    if ! systemctl is-active --quiet docker; then\n      echo -e \"${YELLOW}#${RESET} Docker is installed but not running. Attempting to start Docker...\\\\n\"\n      sudo systemctl start docker\n      if ! systemctl is-active --quiet docker; then\n        echo -e \"${RED}#${RESET} Failed to start Docker. Please check the Docker service status and try again.\"\n        exit 1\n      else\n        echo -e \"${GREEN}#${RESET} Docker service started successfully.\\\\n\"\n      fi\n    else\n      echo -e \"${GREEN}#${RESET} Docker service is already running.\\\\n\"\n    fi\n  fi\n}\n\ncheck_docker_compose() {\n  # Check if 'docker compose' (v2 plugin) is available\n  if ! docker compose version &>/dev/null; then\n    echo -e \"${RED}#${RESET} Docker Compose v2 is not installed or not available as a Docker plugin.\"\n    echo -e \"${YELLOW}#${RESET} This script requires 'docker compose' (v2), not 'docker-compose' (v1).\"\n    echo -e \"${YELLOW}#${RESET} Please read the Docker documentation at https://docs.docker.com/compose/install/ for instructions on how to install Docker Compose v2.\"\n    exit 1\n  fi\n}\n\nsetup_nvidia_container_toolkit() {\n  # This function attempts to set up NVIDIA GPU support but is non-blocking\n  # Any failures will result in warnings but will NOT stop the installation process\n  \n  echo -e \"${YELLOW}#${RESET} Checking for NVIDIA GPU...\\\\n\"\n  \n  # Safely detect NVIDIA GPU\n  local has_nvidia_gpu=false\n  if command -v lspci &> /dev/null; then\n    if lspci 2>/dev/null | grep -i nvidia &> /dev/null; then\n      has_nvidia_gpu=true\n      echo -e \"${GREEN}#${RESET} NVIDIA GPU detected.\\\\n\"\n    fi\n  fi\n  \n  # Also check for nvidia-smi\n  if ! $has_nvidia_gpu && command -v nvidia-smi &> /dev/null; then\n    if nvidia-smi &> /dev/null; then\n      has_nvidia_gpu=true\n      echo -e \"${GREEN}#${RESET} NVIDIA GPU detected via nvidia-smi.\\\\n\"\n    fi\n  fi\n  \n  if ! $has_nvidia_gpu; then\n    echo -e \"${YELLOW}#${RESET} No NVIDIA GPU detected. Skipping NVIDIA container toolkit installation.\\\\n\"\n    return 0\n  fi\n  \n  # Check if nvidia-container-toolkit is already installed\n  if command -v nvidia-ctk &> /dev/null; then\n    echo -e \"${GREEN}#${RESET} NVIDIA container toolkit is already installed.\\\\n\"\n    return 0\n  fi\n  \n  echo -e \"${YELLOW}#${RESET} Installing NVIDIA container toolkit...\\\\n\"\n  \n  # Install dependencies per https://docs.ollama.com/docker - wrapped in error handling\n  if ! curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey 2>/dev/null | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg 2>/dev/null; then\n    echo -e \"${YELLOW}#${RESET} Warning: Failed to add NVIDIA container toolkit GPG key. Continuing anyway...\\\\n\"\n    return 0\n  fi\n  \n  if ! curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list 2>/dev/null \\\n      | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \\\n      | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list > /dev/null 2>&1; then\n    echo -e \"${YELLOW}#${RESET} Warning: Failed to add NVIDIA container toolkit repository. Continuing anyway...\\\\n\"\n    return 0\n  fi\n  \n  if ! sudo apt-get update 2>/dev/null; then\n    echo -e \"${YELLOW}#${RESET} Warning: Failed to update package list. Continuing anyway...\\\\n\"\n    return 0\n  fi\n  \n  if ! sudo apt-get install -y nvidia-container-toolkit 2>/dev/null; then\n    echo -e \"${YELLOW}#${RESET} Warning: Failed to install NVIDIA container toolkit. Continuing anyway...\\\\n\"\n    return 0\n  fi\n  \n  echo -e \"${GREEN}#${RESET} NVIDIA container toolkit installed successfully.\\\\n\"\n  \n  # Configure Docker to use NVIDIA runtime\n  echo -e \"${YELLOW}#${RESET} Configuring Docker to use NVIDIA runtime...\\\\n\"\n  \n  if ! sudo nvidia-ctk runtime configure --runtime=docker 2>/dev/null; then\n    echo -e \"${YELLOW}#${RESET} nvidia-ctk configure failed, attempting manual configuration...\\\\n\"\n    \n    # Fallback: Manually configure daemon.json\n    local daemon_json=\"/etc/docker/daemon.json\"\n    local config_success=false\n    \n    if [[ -f \"$daemon_json\" ]]; then\n      # Backup existing config (best effort)\n      sudo cp \"$daemon_json\" \"${daemon_json}.backup\" 2>/dev/null || true\n      \n      # Check if nvidia runtime already exists\n      if ! grep -q '\"nvidia\"' \"$daemon_json\" 2>/dev/null; then\n        # Add nvidia runtime to existing config using jq if available\n        if command -v jq &> /dev/null; then\n          if sudo jq '. + {\"runtimes\": {\"nvidia\": {\"path\": \"nvidia-container-runtime\", \"runtimeArgs\": []}}}' \"$daemon_json\" > /tmp/daemon.json.tmp 2>/dev/null; then\n            if sudo mv /tmp/daemon.json.tmp \"$daemon_json\" 2>/dev/null; then\n              config_success=true\n            fi\n          fi\n          # Clean up temp file if move failed\n          sudo rm -f /tmp/daemon.json.tmp 2>/dev/null || true\n        else\n          echo -e \"${YELLOW}#${RESET} jq not available, skipping manual daemon.json configuration...\\\\n\"\n        fi\n      else\n        config_success=true  # Already configured\n      fi\n    else\n      # Create new daemon.json with nvidia runtime (best effort)\n      if echo '{\"runtimes\":{\"nvidia\":{\"path\":\"nvidia-container-runtime\",\"runtimeArgs\":[]}}}' | sudo tee \"$daemon_json\" > /dev/null 2>&1; then\n        config_success=true\n      fi\n    fi\n    \n    if ! $config_success; then\n      echo -e \"${YELLOW}#${RESET} Manual daemon.json configuration unsuccessful. GPU support may require manual setup.\\\\n\"\n    fi\n  fi\n  \n  # Restart Docker service\n  echo -e \"${YELLOW}#${RESET} Restarting Docker service...\\\\n\"\n  if ! sudo systemctl restart docker 2>/dev/null; then\n    echo -e \"${YELLOW}#${RESET} Warning: Failed to restart Docker service. You may need to restart it manually.\\\\n\"\n    return 0\n  fi\n  \n  # Verify NVIDIA runtime is available\n  echo -e \"${YELLOW}#${RESET} Verifying NVIDIA runtime configuration...\\\\n\"\n  sleep 2  # Give Docker a moment to fully restart\n  \n  if docker info 2>/dev/null | grep -q \"nvidia\"; then\n    echo -e \"${GREEN}#${RESET} NVIDIA runtime successfully configured and verified.\\\\n\"\n  else\n    echo -e \"${YELLOW}#${RESET} Warning: NVIDIA runtime not detected in Docker info. GPU acceleration may not work.\\\\n\"\n    echo -e \"${YELLOW}#${RESET} You may need to manually configure /etc/docker/daemon.json and restart Docker.\\\\n\"\n  fi\n  \n  echo -e \"${GREEN}#${RESET} NVIDIA container toolkit configuration completed.\\\\n\"\n}\n\nget_install_confirmation(){\n  echo -e \"${YELLOW}#${RESET} This script will install Project N.O.M.A.D. and its dependencies on your machine.\"\n  echo -e \"${YELLOW}#${RESET} If you already have Project N.O.M.A.D. installed with customized config or data, please be aware that running this installation script may overwrite existing files and configurations. It is highly recommended to back up any important data/configs before proceeding.\"\n  read -p \"Are you sure you want to continue? (y/N): \" choice\n  case \"$choice\" in\n    y|Y )\n      echo -e \"${GREEN}#${RESET} User chose to continue with the installation.\"\n      ;;\n    * )\n      echo \"User chose not to continue with the installation.\"\n      exit 0\n      ;;\n  esac\n}\n\naccept_terms() {\n  printf \"\\n\\n\"\n  echo \"License Agreement & Terms of Use\"\n  echo \"__________________________\"\n  printf \"\\n\\n\"\n  echo \"Project N.O.M.A.D. is licensed under the Apache License 2.0. The full license can be found at https://www.apache.org/licenses/LICENSE-2.0 or in the LICENSE file of this repository.\"\n  printf \"\\n\"\n  echo \"By accepting this agreement, you acknowledge that you have read and understood the terms and conditions of the Apache License 2.0 and agree to be bound by them while using Project N.O.M.A.D.\"\n  echo -e \"\\n\\n\"\n  read -p \"I have read and accept License Agreement & Terms of Use (y/N)? \" choice\n  case \"$choice\" in\n    y|Y )\n      accepted_terms='true'\n      ;;\n    * )\n      echo \"License Agreement & Terms of Use not accepted. Installation cannot continue.\"\n      exit 1\n      ;;\n  esac\n}\n\ncreate_nomad_directory(){\n  # Ensure the main installation directory exists\n  if [[ ! -d \"$NOMAD_DIR\" ]]; then\n    echo -e \"${YELLOW}#${RESET} Creating directory for Project N.O.M.A.D at $NOMAD_DIR...\\\\n\"\n    sudo mkdir -p \"$NOMAD_DIR\"\n    sudo chown \"$(whoami):$(whoami)\" \"$NOMAD_DIR\"\n\n    echo -e \"${GREEN}#${RESET} Directory created successfully.\\\\n\"\n  else\n    echo -e \"${GREEN}#${RESET} Directory $NOMAD_DIR already exists.\\\\n\"\n  fi\n\n  # Also ensure the directory has a /storage/logs/ subdirectory\n  sudo mkdir -p \"${NOMAD_DIR}/storage/logs\"\n\n  # Create a admin.log file in the logs directory\n  sudo touch \"${NOMAD_DIR}/storage/logs/admin.log\"\n}\n\ndownload_management_compose_file() {\n  local compose_file_path=\"${NOMAD_DIR}/compose.yml\"\n\n  echo -e \"${YELLOW}#${RESET} Downloading docker-compose file for management...\\\\n\"\n  if ! curl -fsSL \"$MANAGEMENT_COMPOSE_FILE_URL\" -o \"$compose_file_path\"; then\n    echo -e \"${RED}#${RESET} Failed to download the docker compose file. Please check the URL and try again.\"\n    exit 1\n  fi\n  echo -e \"${GREEN}#${RESET} Docker compose file downloaded successfully to $compose_file_path.\\\\n\"\n\n  local app_key=$(generateRandomPass)\n  local db_root_password=$(generateRandomPass)\n  local db_user_password=$(generateRandomPass)\n\n  # Inject dynamic env values into the compose file\n  echo -e \"${YELLOW}#${RESET} Configuring docker-compose file env variables...\\\\n\"\n  sed -i \"s|URL=replaceme|URL=http://${local_ip_address}:8080|g\" \"$compose_file_path\"\n  sed -i \"s|APP_KEY=replaceme|APP_KEY=${app_key}|g\" \"$compose_file_path\"\n  \n  sed -i \"s|DB_PASSWORD=replaceme|DB_PASSWORD=${db_user_password}|g\" \"$compose_file_path\"\n  sed -i \"s|MYSQL_ROOT_PASSWORD=replaceme|MYSQL_ROOT_PASSWORD=${db_root_password}|g\" \"$compose_file_path\"\n  sed -i \"s|MYSQL_PASSWORD=replaceme|MYSQL_PASSWORD=${db_user_password}|g\" \"$compose_file_path\"\n  \n  echo -e \"${GREEN}#${RESET} Docker compose file configured successfully.\\\\n\"\n}\n\ndownload_sidecar_files() {\n  # Create sidecar-updater directory if it doesn't exist\n  if [[ ! -d \"${NOMAD_DIR}/sidecar-updater\" ]]; then\n    sudo mkdir -p \"${NOMAD_DIR}/sidecar-updater\"\n    sudo chown \"$(whoami):$(whoami)\" \"${NOMAD_DIR}/sidecar-updater\"\n  fi\n\n  local sidecar_dockerfile_path=\"${NOMAD_DIR}/sidecar-updater/Dockerfile\"\n  local sidecar_script_path=\"${NOMAD_DIR}/sidecar-updater/update-watcher.sh\"\n\n  echo -e \"${YELLOW}#${RESET} Downloading sidecar updater Dockerfile...\\\\n\"\n  if ! curl -fsSL \"$SIDECAR_UPDATER_DOCKERFILE_URL\" -o \"$sidecar_dockerfile_path\"; then\n    echo -e \"${RED}#${RESET} Failed to download the sidecar updater Dockerfile. Please check the URL and try again.\"\n    exit 1\n  fi\n  echo -e \"${GREEN}#${RESET} Sidecar updater Dockerfile downloaded successfully to $sidecar_dockerfile_path.\\\\n\"\n\n  echo -e \"${YELLOW}#${RESET} Downloading sidecar updater script...\\\\n\"\n  if ! curl -fsSL \"$SIDECAR_UPDATER_SCRIPT_URL\" -o \"$sidecar_script_path\"; then\n    echo -e \"${RED}#${RESET} Failed to download the sidecar updater script. Please check the URL and try again.\"\n    exit 1\n  fi\n  chmod +x \"$sidecar_script_path\"\n  echo -e \"${GREEN}#${RESET} Sidecar updater script downloaded successfully to $sidecar_script_path.\\\\n\"\n}\n\ndownload_helper_scripts() {\n  local start_script_path=\"${NOMAD_DIR}/start_nomad.sh\"\n  local stop_script_path=\"${NOMAD_DIR}/stop_nomad.sh\"\n  local update_script_path=\"${NOMAD_DIR}/update_nomad.sh\"\n\n  echo -e \"${YELLOW}#${RESET} Downloading helper scripts...\\\\n\"\n  if ! curl -fsSL \"$START_SCRIPT_URL\" -o \"$start_script_path\"; then\n    echo -e \"${RED}#${RESET} Failed to download the start script. Please check the URL and try again.\"\n    exit 1\n  fi\n  chmod +x \"$start_script_path\"\n\n  if ! curl -fsSL \"$STOP_SCRIPT_URL\" -o \"$stop_script_path\"; then\n    echo -e \"${RED}#${RESET} Failed to download the stop script. Please check the URL and try again.\"\n    exit 1\n  fi\n  chmod +x \"$stop_script_path\"\n\n  if ! curl -fsSL \"$UPDATE_SCRIPT_URL\" -o \"$update_script_path\"; then\n    echo -e \"${RED}#${RESET} Failed to download the update script. Please check the URL and try again.\"\n    exit 1\n  fi\n  chmod +x \"$update_script_path\"\n\n  echo -e \"${GREEN}#${RESET} Helper scripts downloaded successfully to $start_script_path, $stop_script_path, and $update_script_path.\\\\n\"\n}\n\nstart_management_containers() {\n  echo -e \"${YELLOW}#${RESET} Starting management containers using docker compose...\\\\n\"\n  if ! sudo docker compose -p project-nomad -f \"${NOMAD_DIR}/compose.yml\" up -d; then\n    echo -e \"${RED}#${RESET} Failed to start management containers. Please check the logs and try again.\"\n    exit 1\n  fi\n  echo -e \"${GREEN}#${RESET} Management containers started successfully.\\\\n\"\n}\n\nget_local_ip() {\n  local_ip_address=$(hostname -I | awk '{print $1}')\n  if [[ -z \"$local_ip_address\" ]]; then\n    echo -e \"${RED}#${RESET} Unable to determine local IP address. Please check your network configuration.\"\n    exit 1\n  fi\n}\nverify_gpu_setup() {\n  # This function only displays GPU setup status and is completely non-blocking\n  # It never exits or returns error codes - purely informational\n  \n  echo -e \"\\\\n${YELLOW}#${RESET} GPU Setup Verification\\\\n\"\n  echo -e \"${YELLOW}===========================================${RESET}\\\\n\"\n  \n  # Check if NVIDIA GPU is present\n  if command -v nvidia-smi &> /dev/null; then\n    echo -e \"${GREEN}✓${RESET} NVIDIA GPU detected:\"\n    nvidia-smi --query-gpu=name,memory.total --format=csv,noheader 2>/dev/null | while read -r line; do\n      echo -e \"  ${WHITE_R}$line${RESET}\"\n    done\n    echo \"\"\n  else\n    echo -e \"${YELLOW}○${RESET} No NVIDIA GPU detected (nvidia-smi not available)\\\\n\"\n  fi\n  \n  # Check if NVIDIA Container Toolkit is installed\n  if command -v nvidia-ctk &> /dev/null; then\n    echo -e \"${GREEN}✓${RESET} NVIDIA Container Toolkit installed: $(nvidia-ctk --version 2>/dev/null | head -n1)\\\\n\"\n  else\n    echo -e \"${YELLOW}○${RESET} NVIDIA Container Toolkit not installed\\\\n\"\n  fi\n  \n  # Check if Docker has NVIDIA runtime\n  if docker info 2>/dev/null | grep -q \\\"nvidia\\\"; then\n    echo -e \"${GREEN}✓${RESET} Docker NVIDIA runtime configured\\\\n\"\n  else\n    echo -e \"${YELLOW}○${RESET} Docker NVIDIA runtime not detected\\\\n\"\n  fi\n  \n  # Check for AMD GPU\n  if command -v lspci &> /dev/null; then\n    if lspci 2>/dev/null | grep -iE \"amd|radeon\" &> /dev/null; then\n      echo -e \"${YELLOW}○${RESET} AMD GPU detected (ROCm support not currently available)\\\\n\"\n    fi\n  fi\n  \n  echo -e \"${YELLOW}===========================================${RESET}\\\\n\"\n  \n  # Summary\n  if command -v nvidia-smi &> /dev/null && docker info 2>/dev/null | grep -q \\\"nvidia\\\"; then\n    echo -e \"${GREEN}#${RESET} GPU acceleration is properly configured! The AI Assistant will use your GPU.\\\\n\"\n  else\n    echo -e \"${YELLOW}#${RESET} GPU acceleration not detected. The AI Assistant will run in CPU-only mode.\\\\n\"\n    if command -v nvidia-smi &> /dev/null && ! docker info 2>/dev/null | grep -q \\\"nvidia\\\"; then\n      echo -e \"${YELLOW}#${RESET} Tip: Your GPU is detected but Docker runtime is not configured.\\\\n\"\n      echo -e \"${YELLOW}#${RESET} Try restarting Docker: ${WHITE_R}sudo systemctl restart docker${RESET}\\\\n\"\n    fi\n  fi\n}\n\nsuccess_message() {\n  echo -e \"${GREEN}#${RESET} Project N.O.M.A.D installation completed successfully!\\\\n\"\n  echo -e \"${GREEN}#${RESET} Installation files are located at /opt/project-nomad\\\\n\\n\"\n  echo -e \"${GREEN}#${RESET} Project N.O.M.A.D's Command Center should automatically start whenever your device reboots. However, if you need to start it manually, you can always do so by running: ${WHITE_R}${NOMAD_DIR}/start_nomad.sh${RESET}\\\\n\"\n  echo -e \"${GREEN}#${RESET} You can now access the management interface at http://localhost:8080 or http://${local_ip_address}:8080\\\\n\"\n  echo -e \"${GREEN}#${RESET} Thank you for supporting Project N.O.M.A.D!\\\\n\"\n}\n\n###################################################################################################################################################################################################\n#                                                                                                                                                                                                 #\n#                                                                                           Main Script                                                                                           #\n#                                                                                                                                                                                                 #\n###################################################################################################################################################################################################\n\n# Pre-flight checks\ncheck_is_debian_based\ncheck_is_bash\ncheck_has_sudo\nensure_dependencies_installed\ncheck_is_debug_mode\n\n# Main install\nget_install_confirmation\naccept_terms\nensure_docker_installed\ncheck_docker_compose\nsetup_nvidia_container_toolkit\nget_local_ip\ncreate_nomad_directory\ndownload_sidecar_files\ndownload_helper_scripts\ndownload_management_compose_file\nstart_management_containers\nverify_gpu_setup\nsuccess_message\n\n# free_space_check() {\n#   if [[ \"$(df -B1 / | awk 'NR==2{print $4}')\" -le '5368709120' ]]; then\n#     header_red\n#     echo -e \"${YELLOW}#${RESET} You only have $(df -B1 / | awk 'NR==2{print $4}' | awk '{ split( \"B KB MB GB TB PB EB ZB YB\" , v ); s=1; while( $1>1024 && s<9 ){ $1/=1024; s++ } printf \"%.1f %s\", $1, v[s] }') of disk space available on \\\"/\\\"... \\\\n\"\n#     while true; do\n#       read -rp $'\\033[39m#\\033[0m Do you want to proceed with running the script? (y/N) ' yes_no\n#       case \"$yes_no\" in\n#          [Nn]*|\"\")\n#             free_space_check_response=\"Cancel script\"\n#             free_space_check_date=\"$(date +%s)\"\n#             echo -e \"${YELLOW}#${RESET} OK... Please free up disk space before running the script again...\"\n#             cancel_script\n#             break;;\n#          [Yy]*)\n#             free_space_check_response=\"Proceed at own risk\"\n#             free_space_check_date=\"$(date +%s)\"\n#             echo -e \"${YELLOW}#${RESET} OK... Proceeding with the script.. please note that failures may occur due to not enough disk space... \\\\n\"; sleep 10\n#             break;;\n#          *) echo -e \"\\\\n${RED}#${RESET} Invalid input, please answer Yes or No (y/n)...\\\\n\"; sleep 3;;\n#       esac\n#     done\n#     if [[ -n \"$(command -v jq)\" ]]; then\n#       if [[ \"$(dpkg-query --showformat='${version}' --show jq 2> /dev/null | sed -e 's/.*://' -e 's/-.*//g' -e 's/[^0-9.]//g' -e 's/\\.//g' | sort -V | tail -n1)\" -ge \"16\" && -e \"${eus_dir}/db/db.json\" ]]; then\n#         jq '.scripts.\"'\"${script_name}\"'\" += {\"warnings\": {\"low-free-disk-space\": {\"response\": \"'\"${free_space_check_response}\"'\", \"detected-date\": \"'\"${free_space_check_date}\"'\"}}}' \"${eus_dir}/db/db.json\" > \"${eus_dir}/db/db.json.tmp\" 2>> \"${eus_dir}/logs/eus-database-management.log\"\n#       else\n#         jq '.scripts.\"'\"${script_name}\"'\" = (.scripts.\"'\"${script_name}\"'\" | . + {\"warnings\": {\"low-free-disk-space\": {\"response\": \"'\"${free_space_check_response}\"'\", \"detected-date\": \"'\"${free_space_check_date}\"'\"}}})' \"${eus_dir}/db/db.json\" > \"${eus_dir}/db/db.json.tmp\" 2>> \"${eus_dir}/logs/eus-database-management.log\"\n#       fi\n#       eus_database_move\n#     fi\n#   fi\n# }\n"
  },
  {
    "path": "install/management_compose.yaml",
    "content": "# Project N.O.M.A.D. management services Docker Compose configuration\n#\n# This compose file defines the admin server, database, and other supporting services required to run Project N.O.M.A.D.\n# You can use this with `docker-compose up -d` to start all the necessary services with a single command after installation.\n#\n# Note: we recommend leaving all of the environment variables as-is except for any \"replaceme\" values,\n# which must be updated for the admin server to start successfully. The default values are optimized for ease of installation and use,\n# but you can customize them as needed (e.g. changing ports, database connection details, log level, etc.)\nname: project-nomad\nservices:\n  admin:\n    image: ghcr.io/crosstalk-solutions/project-nomad:latest\n    pull_policy: always\n    container_name: nomad_admin\n    restart: unless-stopped\n    extra_hosts:\n      - \"host.docker.internal:host-gateway\"  # Enables host.docker.internal on Linux\n    ports:\n      - \"8080:8080\"\n    volumes:\n      - /opt/project-nomad/storage:/app/storage # If you change the default storage path (/opt/project-nomad/storage), make sure to update the disk-collector config as well!\n      - /var/run/docker.sock:/var/run/docker.sock # Allows the admin service to communicate with the Host's Docker daemon\n      - nomad-update-shared:/app/update-shared # Shared volume for update communication\n    environment:\n      - NODE_ENV=production\n      # PORT is the port the admin server listens on *inside* the container and should not be changed. If you want to change which port the admin interface is accessible from on the host, you can change the port mapping in the \"ports\" section (e.g. \"9090:8080\" to access it on port 9090 from the host)\n      - PORT=8080\n      - LOG_LEVEL=info\n      # APP_KEY needs to be at least 16 chars or will fail validation and container won't start!\n      - APP_KEY=replaceme\n      # # Leave HOST as is so the admin server listens all interfaces within the container - this doesn't affect how you access it from the host, it's just for internal container networking\n      - HOST=0.0.0.0\n      # URL should be set to the URL you will access the admin interface at (e.g. http://localhost:8080 or http://192.168.1.x:8080)\n      - URL=replaceme\n      - DB_HOST=mysql\n      # If you change the MySQL port, make sure to update this accordingly\n      - DB_PORT=3306\n      - DB_DATABASE=nomad\n      - DB_USER=nomad_user\n      # Needs to match the MYSQL_PASSWORD in the mysql service!\n      - DB_PASSWORD=replaceme\n      - DB_NAME=nomad\n      - DB_SSL=false\n      - REDIS_HOST=redis\n      # If you change the Redis port, make sure to update this accordingly\n      - REDIS_PORT=6379\n    depends_on:\n      mysql:\n        condition: service_healthy\n      redis:\n        condition: service_healthy\n    healthcheck:\n      test: [\"CMD\", \"curl\", \"-f\", \"http://localhost:8080/api/health\"]\n      interval: 30s\n      timeout: 10s\n      retries: 3\n  dozzle:\n    # Dozzle is an optional container that allows for easily viewing container logs. We recommend including it unless you have a specific reason not to. Note that if you don't install it, the \"Service Logs & Metrics\" link in Settings that launches Dozzle will not work.\n    image: amir20/dozzle:v10.0\n    container_name: nomad_dozzle\n    restart: unless-stopped\n    ports:\n      - \"9999:8080\"\n    volumes:\n      - /var/run/docker.sock:/var/run/docker.sock # Allows Dozzle to read logs from the Host's Docker daemon\n    environment:\n      - DOZZLE_ENABLE_ACTIONS=false # Disabled — unauthenticated container stop/restart on LAN\n      - DOZZLE_ENABLE_SHELL=false  # Disabled — shell access + Docker socket = privilege escalation\n  mysql:\n    image: mysql:8.0\n    container_name: nomad_mysql\n    restart: unless-stopped\n    environment:\n      - MYSQL_ROOT_PASSWORD=replaceme\n      - MYSQL_DATABASE=nomad\n      - MYSQL_USER=nomad_user\n      # Needs to match DB_PASSWORD in the admin service!\n      - MYSQL_PASSWORD=replaceme\n    volumes:\n      - /opt/project-nomad/mysql:/var/lib/mysql # Persist MySQL data on the host. This path can be changed if needed, just make sure it's writable by the container. Host persistence is important for the database to ensure your data isn't lost when the container is removed or updated.\n    healthcheck:\n      test: [\"CMD\", \"mysqladmin\", \"ping\", \"-h\", \"localhost\"]\n      interval: 30s\n      timeout: 10s\n      retries: 3\n  redis:\n    image: redis:7-alpine\n    container_name: nomad_redis\n    restart: unless-stopped\n    volumes:\n      - /opt/project-nomad/redis:/data # Persist Redis data on the host. This path can be changed if needed, just make sure it's writable by the container. Host persistence is important for Redis to ensure your data isn't lost when the container is removed or updated.\n    healthcheck:\n      test: [\"CMD\", \"redis-cli\", \"ping\"]\n      interval: 30s\n      timeout: 10s\n      retries: 3\n  updater:\n    # Updater is a lightweight sidecar container that allows the admin container to be updated from within it's own UI\n    image: ghcr.io/crosstalk-solutions/project-nomad-sidecar-updater:latest\n    pull_policy: always\n    container_name: nomad_updater\n    restart: unless-stopped\n    volumes:\n      - /var/run/docker.sock:/var/run/docker.sock # Allows communication with the Host's Docker daemon\n      - /opt/project-nomad:/opt/project-nomad # Writable access required so the updater can set the correct image tag in compose.yml. This needs to be the same location that the compose file is located at on the host for the updater to work correctly\n      - nomad-update-shared:/shared # Shared volume for communication with admin container\n  disk-collector:\n    # Disk Collector is a lightweight privileged container that collects disk usage information from the host system and shares it with the admin container so it can be displayed in the UI. \n    # It requires read-only access to the host filesystem and is designed to be as secure and limited in scope as possible while still providing the necessary functionality.\n    image: ghcr.io/crosstalk-solutions/project-nomad-disk-collector:latest\n    pull_policy: always\n    container_name: nomad_disk_collector\n    restart: unless-stopped\n    volumes:\n      - /:/host:ro,rslave  # Read-only view of host FS with rslave propagation so /sys and /proc submounts are visible\n      - /opt/project-nomad/storage:/storage\n\nvolumes:\n  nomad-update-shared:\n    driver: local"
  },
  {
    "path": "install/migrate-disk-collector.md",
    "content": "# Project N.O.M.A.D. — About the Disk Collector Migration Script\n\nThis script migrates your Project N.O.M.A.D. installation from the old host-based disk info collector to the new disk-collector sidecar. It modifies `/opt/project-nomad/compose.yml` to add the new service and remove the old bind mount, then restarts the full compose stack to apply the changes.\n\n### Why the Migration?\nThe new disk-collector sidecar provides a more robust and scalable way to collect disk information from the host. It removes the original bind mount to `/tmp/nomad-disk-info.json`, which was fragile and prone to issues on host reboots.\n\nThe original host-based collector relied on a process running on the host that wrote disk info to a file, which was then read by the admin container via a bind mount. This approach had several drawbacks:\n- The host process could fail or be killed, leading to stale or missing disk info.\n- The bind mount to `/tmp/nomad-disk-info.json` was cleared on host reboots, causing Docker to create a directory at the mount point instead of a file.\n- Necessitated a tighter coupling to the host, which would make more flexible future deployment options tougher to achieve.\n\nThe migration script automates the necessary changes to your compose configuration and ensures a smooth transition to the new architecture.\n\n### Why does Nomad need the nomad-disk-info.json file?\nNomad uses the disk info stored and updated in `nomad-disk-info.json` to allow users to view disk usage and availability within the Nomad \"Command Center\". While not critical to the core functionality of Nomad, it provides a more pleasant experience for users with limited storage space and/or who aren't familiar with command-line tools and Linux management.\n\n### Why a separate container?\nThe disk-collector runs in a separate container to isolate its functionality from the main admin container. This separation provides several benefits:\n- **Stability**: If the disk-collector encounters an issue or crashes, it won't affect the main admin container and vice versa.\n- **Security**: The main admin container already has significant host access via the Docker socket, storage directory, and host.docker.internal. Additionally, Nomad may add more features in the future that support multi-user environments and/or more network exposure, so isolating the disk-collector reduces the exposure of the host filesystem (even if read-only) to just the one container, which has a very limited scope of functionality and access.\n- **Modularity**: Because having the host disk info is not a critical component of Nomad's core functionality, isolating it in a sidecar allows users who don't need/want the disk info features to simply not run that container, without impacting the main admin container or other services. It also allows for more flexible future development of the disk-collector without needing to modify the main admin container.\n\n### What if I don't want to run the migration script?\nNo worries - you can replicate the changes manually by editing your `/opt/project-nomad/compose.yml` to add the new disk-collector service and remove the old bind mount from the admin service, then restarting your compose stack. The migration script just automates these steps and ensures they're done correctly, but the underlying changes are straightforward if you prefer to do it yourself. Just be sure to back up your `compose.yml` before making any changes.\n\nHere's the disk-collector service configuration to add to your `compose.yml`:\n\n```yml\n  disk-collector:\n    image: ghcr.io/crosstalk-solutions/project-nomad-disk-collector:latest\n    pull_policy: always\n    container_name: nomad_disk_collector\n    restart: unless-stopped\n    volumes:\n      - /:/host:ro,rslave  # Read-only view of host FS with rslave propagation so /sys and /proc submounts are visible\n      - /opt/project-nomad/storage:/storage\n```\n\nand remove the `- /tmp/nomad-disk-info.json:/app/storage/nomad-disk-info.json` bind mount from the admin service volumes."
  },
  {
    "path": "install/migrate-disk-collector.sh",
    "content": "#!/bin/bash\n\n# Project N.O.M.A.D. — Disk Collector Migration Script\n#\n# Script                | Project N.O.M.A.D. Disk Collector Migration Script\n# Version               | 1.0.0\n# Author                | Crosstalk Solutions, LLC\n# Website               | https://crosstalksolutions.com\n#\n# PURPOSE:\n#   One-time migration from the host-based disk info collector to the\n#   disk-collector Docker sidecar. The old approach used a nohup background\n#   process that wrote to /tmp/nomad-disk-info.json, which was bind-mounted\n#   into the admin container. This broke on host reboots because /tmp is\n#   cleared and Docker would create a directory at the mount point instead of a file.\n#\n#   The new approach uses a disk-collector sidecar container that reads host\n#   disk info via the /:/host:ro,rslave bind-mount pattern (same pattern as Prometheus\n#   node-exporter, and no SYS_ADMIN or privileged capabilities required) and writes directly to\n#   /opt/project-nomad/storage/nomad-disk-info.json, which the admin container\n#   already reads via its existing storage bind-mount. Thus, no admin image update\n#   or new volume mounts required.\n\n###############################################################################\n# Color Codes\n###############################################################################\n\nRESET='\\033[0m'\nYELLOW='\\033[1;33m'\nRED='\\033[1;31m'\nGREEN='\\033[1;32m'\nWHITE_R='\\033[39m'\n\n###############################################################################\n# Constants\n###############################################################################\n\nNOMAD_DIR=\"/opt/project-nomad\"\nCOMPOSE_FILE=\"${NOMAD_DIR}/compose.yml\"\nCOMPOSE_PROJECT_NAME=\"project-nomad\"\n\n###############################################################################\n# Pre-flight Checks\n###############################################################################\n\ncheck_is_bash() {\n  if [[ -z \"$BASH_VERSION\" ]]; then\n    echo -e \"${RED}#${RESET} This script must be run with bash.\"\n    echo -e \"${RED}#${RESET} Example: bash $(basename \"$0\")\"\n    exit 1\n  fi\n  echo -e \"${GREEN}#${RESET} Running in bash.\\n\"\n}\n\ncheck_has_sudo() {\n  if sudo -n true 2>/dev/null; then\n    echo -e \"${GREEN}#${RESET} Sudo permissions confirmed.\\n\"\n  else\n    echo -e \"${RED}#${RESET} This script requires sudo permissions.\"\n    echo -e \"${RED}#${RESET} Example: sudo bash $(basename \"$0\")\"\n    exit 1\n  fi\n}\n\ncheck_confirmation() {\n  echo -e \"${YELLOW}#${RESET} This script migrates your Project N.O.M.A.D. installation from the\"\n  echo -e \"${YELLOW}#${RESET} host-based disk info collector to the new disk-collector sidecar.\"\n  echo -e \"${YELLOW}#${RESET} It will modify compose.yml and restart the full compose stack\"\n  echo -e \"${YELLOW}#${RESET} to drop the old /tmp bind mount and start the disk-collector sidecar.\"\n  echo -e \"${YELLOW}#${RESET} Please ensure you have a backup of your data before proceeding.\\n\"\n\n  echo -e \"${RED}#${RESET} STOP: If you have customized your compose.yml or Nomad's storage setup (not common), please make these changes manually instead of using this script!\\n\"\n  read -rp \"Do you want to continue? (y/N) \" response\n  if [[ ! \"$response\" =~ ^[Yy]$ ]]; then\n    echo -e \"${RED}#${RESET} Aborting. No changes have been made.\"\n    exit 0\n  fi\n  echo -e \"${GREEN}#${RESET} Confirmation received. Proceeding with migration...\\n\"\n}\n\ncheck_docker_running() {\n  if ! command -v docker &>/dev/null; then\n    echo -e \"${RED}#${RESET} Docker is not installed. Cannot proceed.\"\n    exit 1\n  fi\n  if ! systemctl is-active --quiet docker; then\n    echo -e \"${RED}#${RESET} Docker is not running. Please start Docker and try again.\"\n    exit 1\n  fi\n  echo -e \"${GREEN}#${RESET} Docker is running.\\n\"\n}\n\ncheck_compose_file() {\n  if [[ ! -f \"$COMPOSE_FILE\" ]]; then\n    echo -e \"${RED}#${RESET} compose.yml not found at ${COMPOSE_FILE}.\"\n    echo -e \"${RED}#${RESET} Project N.O.M.A.D. does not appear to be installed or compose.yml is missing.\"\n    exit 1\n  fi\n  echo -e \"${GREEN}#${RESET} Found compose.yml at ${COMPOSE_FILE}.\\n\"\n}\n\n# Step 1: Stop old host process\nstop_old_host_process() {\n  local pid_file=\"${NOMAD_DIR}/nomad-collect-disk-info.pid\"\n\n  if [[ -f \"$pid_file\" ]]; then\n    echo -e \"${YELLOW}#${RESET} Stopping old collect-disk-info background process...\"\n    local pid\n    pid=$(cat \"$pid_file\")\n    if kill \"$pid\" 2>/dev/null; then\n      echo -e \"${GREEN}#${RESET} Process ${pid} stopped.\\n\"\n    else\n      echo -e \"${YELLOW}#${RESET} Process ${pid} was not running (already stopped).\\n\"\n    fi\n    rm -f \"$pid_file\"\n  else\n    echo -e \"${GREEN}#${RESET} No old collect-disk-info PID file found — nothing to stop.\\n\"\n  fi\n}\n\n# Step 2: Backup compose.yml\nbackup_compose_file() {\n  local backup=\"${COMPOSE_FILE}.bak.$(date +%Y%m%d%H%M%S)\"\n  echo -e \"${YELLOW}#${RESET} Backing up compose.yml to ${backup}...\"\n  if cp \"$COMPOSE_FILE\" \"$backup\"; then\n    echo -e \"${GREEN}#${RESET} Backup created at ${backup}.\\n\"\n  else\n    echo -e \"${RED}#${RESET} Failed to create backup. Aborting.\"\n    exit 1\n  fi\n}\n\n# Step 3: Remove old bind-mount from admin volumes\nremove_old_bind_mount() {\n  if ! grep -q 'nomad-disk-info\\.json' \"$COMPOSE_FILE\"; then\n    echo -e \"${GREEN}#${RESET} Old /tmp/nomad-disk-info.json bind-mount not found — already removed.\\n\"\n    return 0\n  fi\n\n  echo -e \"${YELLOW}#${RESET} Removing old /tmp/nomad-disk-info.json bind-mount from admin volumes...\"\n  sed -i '/\\/tmp\\/nomad-disk-info\\.json:\\/app\\/storage\\/nomad-disk-info\\.json/d' \"$COMPOSE_FILE\"\n\n  if grep -q 'nomad-disk-info\\.json' \"$COMPOSE_FILE\"; then\n    echo -e \"${RED}#${RESET} Failed to remove old bind-mount from compose.yml. Please remove it manually:\"\n    echo -e \"${WHITE_R}      - /tmp/nomad-disk-info.json:/app/storage/nomad-disk-info.json${RESET}\"\n    exit 1\n  fi\n\n  echo -e \"${GREEN}#${RESET} Old bind-mount removed.\\n\"\n}\n\n# Step 4: Add disk-collector service block\nadd_disk_collector_service() {\n  if grep -q 'disk-collector:' \"$COMPOSE_FILE\"; then\n    echo -e \"${GREEN}#${RESET} disk-collector service already present in compose.yml — skipping.\\n\"\n    return 0\n  fi\n\n  echo -e \"${YELLOW}#${RESET} Adding disk-collector service to compose.yml...\"\n\n  # Insert the disk-collector service block before the top-level `volumes:` key\n  awk '/^volumes:/{\n    print \"  disk-collector:\"\n    print \"    image: ghcr.io/crosstalk-solutions/project-nomad-disk-collector:latest\"\n    print \"    pull_policy: always\"\n    print \"    container_name: nomad_disk_collector\"\n    print \"    restart: unless-stopped\"\n    print \"    volumes:\"\n    print \"      - /:/host:ro,rslave  # Read-only view of host FS with rslave propagation so /sys and /proc submounts are visible\"\n    print \"      - /opt/project-nomad/storage:/storage  # Shared storage dir — disk info written here is read by the admin container\"\n    print \"\"\n  }\n  {print}' \"$COMPOSE_FILE\" > \"${COMPOSE_FILE}.tmp\" && mv \"${COMPOSE_FILE}.tmp\" \"$COMPOSE_FILE\"\n\n  if ! grep -q 'disk-collector:' \"$COMPOSE_FILE\"; then\n    echo -e \"${RED}#${RESET} Failed to add disk-collector service. Please add it manually before the top-level volumes: key.\"\n    exit 1\n  fi\n\n  echo -e \"${GREEN}#${RESET} disk-collector service added.\\n\"\n}\n\n# Step 5 — Pull new image and restart the full stack\n# This will re-create the admin container and drop the old /tmp bind, and\n# also starts the new disk-collector sidecar we just added to compose.yml\nrestart_stack() {\n  echo -e \"${YELLOW}#${RESET} Pulling latest images (including disk-collector)...\"\n  if ! docker compose -p \"$COMPOSE_PROJECT_NAME\" -f \"$COMPOSE_FILE\" pull; then\n    echo -e \"${RED}#${RESET} Failed to pull images. Check your network connection.\"\n    exit 1\n  fi\n  echo -e \"${GREEN}#${RESET} Images pulled.\\n\"\n\n  echo -e \"${YELLOW}#${RESET} Restarting stack...\"\n  if ! docker compose -p \"$COMPOSE_PROJECT_NAME\" -f \"$COMPOSE_FILE\" up -d; then\n    echo -e \"${RED}#${RESET} Failed to bring the stack up.\"\n    exit 1\n  fi\n  echo -e \"${GREEN}#${RESET} Stack restarted.\\n\"\n}\n\n# Step 6: Verify\nverify_disk_collector_running() {\n  sleep 3\n  if docker ps --filter \"name=^nomad_disk_collector$\" --filter \"status=running\" --format '{{.Names}}' | grep -qx \"nomad_disk_collector\"; then\n    echo -e \"${GREEN}#${RESET} disk-collector container is running.\\n\"\n  else\n    echo -e \"${RED}#${RESET} disk-collector container does not appear to be running.\"\n    echo -e \"${RED}#${RESET} Check its logs with: docker logs nomad_disk_collector\"\n    exit 1\n  fi\n}\n\n# Main\necho -e \"${GREEN}#########################################################################${RESET}\"\necho -e \"${GREEN}#${RESET}      Project N.O.M.A.D. — Disk Collector Migration Script             ${GREEN}#${RESET}\"\necho -e \"${GREEN}#########################################################################${RESET}\\n\"\n\ncheck_is_bash\ncheck_has_sudo\ncheck_confirmation\ncheck_docker_running\ncheck_compose_file\n\necho -e \"${YELLOW}#${RESET} Step 1: Stopping old host process...\\n\"\nstop_old_host_process\n\necho -e \"${YELLOW}#${RESET} Step 2: Backing up compose.yml...\\n\"\nbackup_compose_file\n\necho -e \"${YELLOW}#${RESET} Step 3: Removing old bind-mount...\\n\"\nremove_old_bind_mount\n\necho -e \"${YELLOW}#${RESET} Step 4: Adding disk-collector service...\\n\"\nadd_disk_collector_service\n\necho -e \"${YELLOW}#${RESET} Step 5: Pulling images and restarting stack...\\n\"\nrestart_stack\n\necho -e \"${YELLOW}#${RESET} Step 6: Verifying disk-collector is running...\\n\"\nverify_disk_collector_running\n\necho -e \"${GREEN}#########################################################################${RESET}\"\necho -e \"${GREEN}#${RESET} Migration completed successfully!\"\necho -e \"${GREEN}#${RESET}\"\necho -e \"${GREEN}#${RESET} The disk-collector sidecar is now running and will update disk info\"\necho -e \"${GREEN}#${RESET} every 2 minutes. The /api/system/info endpoint will return disk data\"\necho -e \"${GREEN}#${RESET} after the first collector write (~5 seconds after startup).\"\necho -e \"${GREEN}#${RESET}\"\necho -e \"${GREEN}#########################################################################${RESET}\\n\"\n"
  },
  {
    "path": "install/run_updater_fixes.sh",
    "content": "#!/bin/bash\n\n# Project N.O.M.A.D. - One-Time Updater Fix Script\n#\n# Script                | Project N.O.M.A.D. One-Time Updater Fix Script\n# Version               | 1.0.0\n# Author                | Crosstalk Solutions, LLC\n# Website               | https://crosstalksolutions.com\n#\n# PURPOSE:\n#   This is a one-time migration script. It deploys two fixes to the sidecar\n#   updater that cannot be applied through the normal in-app update mechanism:\n#\n#   Fix 1 — Sidecar volume write access\n#     Removes the :ro (read-only) flag from the sidecar's /opt/project-nomad\n#     volume mount in compose.yml. The sidecar must be able to write to\n#     compose.yml so it can set the correct Docker image tag when installing\n#     RC or stable versions.\n#\n#   Fix 2 — RC-aware sidecar watcher\n#     Downloads the updated sidecar Dockerfile (adds jq) and update-watcher.sh\n#     (reads target_tag from the update request and applies it to compose.yml\n#     before pulling images), then rebuilds and restarts the sidecar container.\n#\n#   NOTE: The companion fix in the admin service (system_update_service.ts,\n#   which writes the target_tag into the update request) ships in the GHCR\n#   image and will take effect automatically on the next normal app update.\n\n###############################################################################\n# Color Codes\n###############################################################################\n\nRESET='\\033[0m'\nYELLOW='\\033[1;33m'\nRED='\\033[1;31m'\nGREEN='\\033[1;32m'\nWHITE_R='\\033[39m'\n\n###############################################################################\n# Constants\n###############################################################################\n\nNOMAD_DIR=\"/opt/project-nomad\"\nCOMPOSE_FILE=\"${NOMAD_DIR}/compose.yml\"\nSIDECAR_DIR=\"${NOMAD_DIR}/sidecar-updater\"\nCOMPOSE_PROJECT_NAME=\"project-nomad\"\n\nSIDECAR_DOCKERFILE_URL=\"https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/sidecar-updater/Dockerfile\"\nSIDECAR_SCRIPT_URL=\"https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/sidecar-updater/update-watcher.sh\"\n\n###############################################################################\n# Pre-flight Checks\n###############################################################################\n\ncheck_is_bash() {\n  if [[ -z \"$BASH_VERSION\" ]]; then\n    echo -e \"${RED}#${RESET} This script must be run with bash.\"\n    echo -e \"${RED}#${RESET} Example: bash $(basename \"$0\")\"\n    exit 1\n  fi\n  echo -e \"${GREEN}#${RESET} Running in bash.\\n\"\n}\n\ncheck_confirmation() {\n  echo -e \"${YELLOW}#${RESET} This is a very specific fix script for a very specific issue. You probably don't need to run this unless you were specifically directed to by the N.O.M.A.D. team.\"\n  echo -e \"${YELLOW}#${RESET} Please ensure you have a backup of your data before proceeding.\"\n  read -rp \"Do you want to continue? (y/N) \" response\n  if [[ ! \"$response\" =~ ^[Yy]$ ]]; then\n    echo -e \"${RED}#${RESET} Aborting. No changes have been made.\"\n    exit 0\n  fi\n  echo -e \"${GREEN}#${RESET} Confirmation received. Proceeding with fixes...\\n\"\n}\n\ncheck_has_sudo() {\n  if sudo -n true 2>/dev/null; then\n    echo -e \"${GREEN}#${RESET} Sudo permissions confirmed.\\n\"\n  else\n    echo -e \"${RED}#${RESET} This script requires sudo permissions.\"\n    echo -e \"${RED}#${RESET} Example: sudo bash $(basename \"$0\")\"\n    exit 1\n  fi\n}\n\ncheck_docker_running() {\n  if ! command -v docker &>/dev/null; then\n    echo -e \"${RED}#${RESET} Docker is not installed. Cannot proceed.\"\n    exit 1\n  fi\n  if ! systemctl is-active --quiet docker; then\n    echo -e \"${RED}#${RESET} Docker is not running. Please start Docker and try again.\"\n    exit 1\n  fi\n  echo -e \"${GREEN}#${RESET} Docker is running.\\n\"\n}\n\ncheck_compose_file() {\n  if [[ ! -f \"$COMPOSE_FILE\" ]]; then\n    echo -e \"${RED}#${RESET} compose.yml not found at ${COMPOSE_FILE}.\"\n    echo -e \"${RED}#${RESET} Please ensure Project N.O.M.A.D. is installed before running this script.\"\n    exit 1\n  fi\n  echo -e \"${GREEN}#${RESET} Found compose.yml at ${COMPOSE_FILE}.\\n\"\n}\n\ncheck_sidecar_dir() {\n  if [[ ! -d \"$SIDECAR_DIR\" ]]; then\n    echo -e \"${RED}#${RESET} Sidecar directory not found at ${SIDECAR_DIR}.\"\n    echo -e \"${RED}#${RESET} Please ensure Project N.O.M.A.D. is installed before running this script.\"\n    exit 1\n  fi\n  echo -e \"${GREEN}#${RESET} Found sidecar directory at ${SIDECAR_DIR}.\\n\"\n}\n\n###############################################################################\n# Fix 1 — Remove :ro from sidecar volume mount\n###############################################################################\n\nbackup_compose_file() {\n  local backup=\"${COMPOSE_FILE}.bak.$(date +%Y%m%d%H%M%S)\"\n  echo -e \"${YELLOW}#${RESET} Backing up compose.yml to ${backup}...\"\n  if cp \"$COMPOSE_FILE\" \"$backup\"; then\n    echo -e \"${GREEN}#${RESET} Backup created at ${backup}.\\n\"\n  else\n    echo -e \"${RED}#${RESET} Failed to create backup. Aborting.\"\n    exit 1\n  fi\n}\n\nfix_sidecar_volume_mount() {\n  # Idempotent: skip if :ro is already absent from the sidecar mount line\n  if ! grep -q '/opt/project-nomad:/opt/project-nomad:ro' \"$COMPOSE_FILE\"; then\n    echo -e \"${GREEN}#${RESET} Sidecar volume mount is already writable — no change needed.\\n\"\n    return 0\n  fi\n\n  echo -e \"${YELLOW}#${RESET} Removing :ro restriction from sidecar volume mount in compose.yml...\"\n  sed -i 's|/opt/project-nomad:/opt/project-nomad:ro.*|/opt/project-nomad:/opt/project-nomad # Writable access required so the updater can set the correct image tag in compose.yml|' \"$COMPOSE_FILE\"\n\n  if grep -q '/opt/project-nomad:/opt/project-nomad:ro' \"$COMPOSE_FILE\"; then\n    echo -e \"${RED}#${RESET} Failed to remove :ro from compose.yml. Please update it manually:\"\n    echo -e \"${WHITE_R}    - /opt/project-nomad:/opt/project-nomad:ro${RESET}  →  ${WHITE_R}- /opt/project-nomad:/opt/project-nomad${RESET}\"\n    exit 1\n  fi\n\n  echo -e \"${GREEN}#${RESET} Sidecar volume mount updated successfully.\\n\"\n}\n\n###############################################################################\n# Fix 2 — Download updated sidecar files and rebuild\n###############################################################################\n\ndownload_updated_sidecar_files() {\n  echo -e \"${YELLOW}#${RESET} Downloading updated sidecar Dockerfile...\"\n  if ! curl -fsSL \"$SIDECAR_DOCKERFILE_URL\" -o \"${SIDECAR_DIR}/Dockerfile\"; then\n    echo -e \"${RED}#${RESET} Failed to download sidecar Dockerfile. Check your network connection.\"\n    exit 1\n  fi\n  echo -e \"${GREEN}#${RESET} Sidecar Dockerfile updated.\\n\"\n\n  echo -e \"${YELLOW}#${RESET} Downloading updated update-watcher.sh...\"\n  if ! curl -fsSL \"$SIDECAR_SCRIPT_URL\" -o \"${SIDECAR_DIR}/update-watcher.sh\"; then\n    echo -e \"${RED}#${RESET} Failed to download update-watcher.sh. Check your network connection.\"\n    exit 1\n  fi\n  chmod +x \"${SIDECAR_DIR}/update-watcher.sh\"\n  echo -e \"${GREEN}#${RESET} update-watcher.sh updated.\\n\"\n}\n\nrebuild_sidecar() {\n  echo -e \"${YELLOW}#${RESET} Rebuilding the updater container (this may take a moment)...\"\n  if ! docker compose -p \"$COMPOSE_PROJECT_NAME\" -f \"$COMPOSE_FILE\" build updater; then\n    echo -e \"${RED}#${RESET} Failed to rebuild the updater container. See output above for details.\"\n    exit 1\n  fi\n  echo -e \"${GREEN}#${RESET} Updater container rebuilt successfully.\\n\"\n}\n\nrestart_sidecar() {\n  echo -e \"${YELLOW}#${RESET} Stopping and removing existing updater containers...\"\n\n  # Stop and remove via compose first (handles the compose-tracked container)\n  docker compose -p \"$COMPOSE_PROJECT_NAME\" -f \"$COMPOSE_FILE\" stop updater >> /dev/null 2>&1 || true\n  docker compose -p \"$COMPOSE_PROJECT_NAME\" -f \"$COMPOSE_FILE\" rm -f updater >> /dev/null 2>&1 || true\n\n  # Force-remove any stale container still holding the name (e.g. hash-prefixed remnants)\n  docker rm -f nomad_updater >> /dev/null 2>&1 || true\n\n  echo -e \"${YELLOW}#${RESET} Starting the updated updater container...\"\n  if ! docker compose -p \"$COMPOSE_PROJECT_NAME\" -f \"$COMPOSE_FILE\" up -d updater; then\n    echo -e \"${RED}#${RESET} Failed to start the updater container.\"\n    exit 1\n  fi\n  echo -e \"${GREEN}#${RESET} Updater container started.\\n\"\n}\n\nverify_sidecar_running() {\n  sleep 3\n  # Use exact name match to avoid false positives from hash-prefixed stale containers\n  if docker ps --filter \"name=^nomad_updater$\" --filter \"status=running\" --format '{{.Names}}' | grep -qx \"nomad_updater\"; then\n    echo -e \"${GREEN}#${RESET} Updater container is running.\\n\"\n  else\n    echo -e \"${RED}#${RESET} Updater container does not appear to be running.\"\n    echo -e \"${RED}#${RESET} Check its logs with: docker logs nomad_updater\"\n    exit 1\n  fi\n}\n\n###############################################################################\n# Main\n###############################################################################\n\necho -e \"${GREEN}#########################################################################${RESET}\"\necho -e \"${GREEN}#${RESET}         Project N.O.M.A.D. — One-Time Updater Fix Script               ${GREEN}#${RESET}\"\necho -e \"${GREEN}#########################################################################${RESET}\\n\"\n\ncheck_is_bash\ncheck_has_sudo\ncheck_confirmation\ncheck_docker_running\ncheck_compose_file\ncheck_sidecar_dir\n\necho -e \"${YELLOW}#${RESET} Starting Fix 1: Sidecar volume write access...\\n\"\nbackup_compose_file\nfix_sidecar_volume_mount\n\necho -e \"${YELLOW}#${RESET} Starting Fix 2: RC-aware sidecar watcher...\\n\"\ndownload_updated_sidecar_files\nrebuild_sidecar\nrestart_sidecar\nverify_sidecar_running\n\necho -e \"${GREEN}#########################################################################${RESET}\"\necho -e \"${GREEN}#${RESET} All fixes applied successfully!\"\necho -e \"${GREEN}#${RESET}\"\necho -e \"${GREEN}#${RESET} The updater sidecar can now install RC and stable versions correctly.\"\necho -e \"${GREEN}#${RESET} The remaining fix (admin service target_tag support) will apply\"\necho -e \"${GREEN}#${RESET} automatically the next time you update N.O.M.A.D. via the UI.\"\necho -e \"${GREEN}#########################################################################${RESET}\\n\"\n"
  },
  {
    "path": "install/sidecar-disk-collector/Dockerfile",
    "content": "FROM alpine:3.20\nRUN apk add --no-cache util-linux bash\nCOPY collect-disk-info.sh /usr/local/bin/collect-disk-info.sh\nRUN chmod +x /usr/local/bin/collect-disk-info.sh && mkdir -p /storage\nWORKDIR /storage\nCMD [\"/usr/local/bin/collect-disk-info.sh\"]\n"
  },
  {
    "path": "install/sidecar-disk-collector/collect-disk-info.sh",
    "content": "#!/bin/bash\n\n# Project N.O.M.A.D. - Disk Info Collector Sidecar\n#\n# Reads host block device and filesystem info via the /:/host:ro,rslave bind-mount.\n# No special capabilities required. Writes JSON to /storage/nomad-disk-info.json, which is read by the admin container.\n# Runs continually and updates the JSON data every 2 minutes.\n\nlog() {\n    echo \"[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*\"\n}\n\nlog \"disk-collector sidecar starting...\"\n\n# Write a valid placeholder immediately so admin has something to parse if the\n# file is missing (first install, user deleted it, etc.). The real data from the\n# first full collection cycle below will overwrite this within seconds.\nif [[ ! -f /storage/nomad-disk-info.json ]]; then\n    echo '{\"diskLayout\":{\"blockdevices\":[]},\"fsSize\":[]}' > /storage/nomad-disk-info.json\n    log \"Created initial placeholder — will be replaced after first collection.\"\nfi\n\nwhile true; do\n\n    # Get disk layout (-b outputs SIZE in bytes as a number rather than a human-readable string)\n    DISK_LAYOUT=$(lsblk --sysroot /host --json -b -o NAME,SIZE,TYPE,MODEL,SERIAL,VENDOR,ROTA,TRAN 2>/dev/null)\n    if [[ -z \"$DISK_LAYOUT\" ]]; then\n        log \"WARNING: lsblk --sysroot /host failed, using empty block devices\"\n        DISK_LAYOUT='{\"blockdevices\":[]}'\n    fi\n\n    # Get filesystem usage by parsing /host/proc/1/mounts (PID 1 = host init = root mount namespace)\n    # /host/proc/mounts is a symlink to /proc/self/mounts, which always reflects the CURRENT\n    # process's mount namespace (the container's), not the host's. /proc/1/mounts reflects the\n    # host init process's namespace, giving us the true host mount table.\n    FS_JSON=\"[\"\n    FIRST=1\n    while IFS=' ' read -r dev mountpoint fstype opts _rest; do\n        # Disregard pseudo and virtual filesystems\n        [[ \"$fstype\" =~ ^(tmpfs|devtmpfs|squashfs|sysfs|proc|devpts|cgroup|cgroup2|overlay|nsfs|autofs|hugetlbfs|mqueue|pstore|fusectl|binfmt_misc)$ ]] && continue\n        [[ \"$mountpoint\" == \"none\" ]] && continue\n\n        # Skip Docker bind-mounts to individual files (e.g., /etc/resolv.conf, /etc/hostname, /etc/hosts)\n        # These are not real filesystem roots and report misleading sizes\n        [[ -f \"/host${mountpoint}\" ]] && continue\n\n        STATS=$(df -B1 \"/host${mountpoint}\" 2>/dev/null | awk 'NR==2{print $2,$3,$4,$5}')\n        [[ -z \"$STATS\" ]] && continue\n\n        read -r size used avail pct <<< \"$STATS\"\n        pct=\"${pct/\\%/}\"\n\n        [[ \"$FIRST\" -eq 0 ]] && FS_JSON+=\",\"\n        FS_JSON+=\"{\\\"fs\\\":\\\"${dev}\\\",\\\"size\\\":${size},\\\"used\\\":${used},\\\"available\\\":${avail},\\\"use\\\":${pct},\\\"mount\\\":\\\"${mountpoint}\\\"}\"\n        FIRST=0\n    done < /host/proc/1/mounts\n\n    # Fallback: if no real filesystems were found from the host mount table\n    # (e.g. /host/proc/1/mounts was unreadable), try the /storage mount directly.\n    # The disk-collector container always has /storage bind-mounted from the host,\n    # so df on /storage reflects the actual backing device and its capacity.\n    if [[ \"$FIRST\" -eq 1 ]] && mountpoint -q /storage 2>/dev/null; then\n        STATS=$(df -B1 /storage 2>/dev/null | awk 'NR==2{print $1,$2,$3,$4,$5}')\n        if [[ -n \"$STATS\" ]]; then\n            read -r dev size used avail pct <<< \"$STATS\"\n            pct=\"${pct/\\%/}\"\n            FS_JSON+=\"{\\\"fs\\\":\\\"${dev}\\\",\\\"size\\\":${size},\\\"used\\\":${used},\\\"available\\\":${avail},\\\"use\\\":${pct},\\\"mount\\\":\\\"/storage\\\"}\"\n            FIRST=0\n            log \"Used /storage mount as fallback for filesystem info.\"\n        fi\n    fi\n\n    FS_JSON+=\"]\"\n\n    # Use a tmp file for atomic update\n    cat > /storage/nomad-disk-info.json.tmp << EOF\n{\n\"diskLayout\": ${DISK_LAYOUT},\n\"fsSize\": ${FS_JSON}\n}\nEOF\n\n    if mv /storage/nomad-disk-info.json.tmp /storage/nomad-disk-info.json; then\n        log \"Disk info updated successfully.\"\n    else\n        log \"ERROR: Failed to move temp file to /storage/nomad-disk-info.json\"\n    fi\n\n    sleep 120\ndone\n"
  },
  {
    "path": "install/sidecar-updater/Dockerfile",
    "content": "FROM alpine:3.20\n\n# Install Docker CLI for compose operations\nRUN apk add --no-cache docker-cli docker-cli-compose bash jq\n\n# Copy the update watcher script\nCOPY update-watcher.sh /usr/local/bin/update-watcher.sh\nRUN chmod +x /usr/local/bin/update-watcher.sh\n\n# Create shared communication directory\nRUN mkdir -p /shared\n\nWORKDIR /shared\n\nCMD [\"/usr/local/bin/update-watcher.sh\"]\n"
  },
  {
    "path": "install/sidecar-updater/update-watcher.sh",
    "content": "#!/bin/bash\n\n# Project N.O.M.A.D. Update Sidecar - Polls for update requests and executes them\n\nSHARED_DIR=\"/shared\"\nREQUEST_FILE=\"${SHARED_DIR}/update-request\"\nSTATUS_FILE=\"${SHARED_DIR}/update-status\"\nLOG_FILE=\"${SHARED_DIR}/update-log\"\nCOMPOSE_FILE=\"/opt/project-nomad/compose.yml\"\nCOMPOSE_PROJECT_NAME=\"project-nomad\"\n\nlog() {\n    echo \"[$(date '+%Y-%m-%d %H:%M:%S')] $1\" | tee -a \"$LOG_FILE\"\n}\n\nwrite_status() {\n    local stage=\"$1\"\n    local progress=\"$2\"\n    local message=\"$3\"\n    \n    cat > \"$STATUS_FILE\" <<EOF\n{\n  \"stage\": \"$stage\",\n  \"progress\": $progress,\n  \"message\": \"$message\",\n  \"timestamp\": \"$(date -Iseconds)\"\n}\nEOF\n}\n\nperform_update() {\n    local target_tag=\"$1\"\n\n    log \"Update request received - starting system update (target tag: ${target_tag})\"\n\n    # Clear old logs\n    > \"$LOG_FILE\"\n\n    # Stage 1: Starting\n    write_status \"starting\" 0 \"System update initiated\"\n    log \"System update initiated\"\n    sleep 1\n\n    # Apply target image tag to compose.yml before pulling\n    log \"Applying image tag '${target_tag}' to compose.yml...\"\n    if sed -i \"s|\\(image: ghcr\\.io/crosstalk-solutions/project-nomad\\):.*|\\1:${target_tag}|\" \"$COMPOSE_FILE\" 2>> \"$LOG_FILE\"; then\n        log \"Successfully updated compose.yml admin image tag to '${target_tag}'\"\n    else\n        log \"ERROR: Failed to update compose.yml image tag\"\n        write_status \"error\" 0 \"Failed to update compose.yml image tag - check logs\"\n        return 1\n    fi\n\n    # Stage 2: Pulling images\n    write_status \"pulling\" 20 \"Pulling latest Docker images...\"\n    log \"Pulling latest Docker images...\"\n\n    if docker compose -p \"$COMPOSE_PROJECT_NAME\" -f \"$COMPOSE_FILE\" pull >> \"$LOG_FILE\" 2>&1; then\n        log \"Successfully pulled latest images\"\n        write_status \"pulled\" 60 \"Images pulled successfully\"\n    else\n        log \"ERROR: Failed to pull images\"\n        write_status \"error\" 0 \"Failed to pull Docker images - check logs\"\n        return 1\n    fi\n    \n    sleep 2\n    \n    # Stage 3: Recreating containers individually (excluding updater)\n    write_status \"recreating\" 65 \"Recreating containers individually...\"\n    log \"Recreating containers individually (excluding updater)...\"\n    \n    # List of services to update (excluding updater)\n    SERVICES_TO_UPDATE=\"admin mysql redis dozzle\"\n    \n    local current_progress=65\n    local progress_per_service=8  # (95 - 65) / 4 services ≈ 8% per service\n    \n    for service in $SERVICES_TO_UPDATE; do\n        log \"Updating service: $service\"\n        write_status \"recreating\" $current_progress \"Recreating $service...\"\n        \n        # Stop the service\n        log \"  Stopping $service...\"\n        docker compose -p \"$COMPOSE_PROJECT_NAME\" -f \"$COMPOSE_FILE\" stop \"$service\" >> \"$LOG_FILE\" 2>&1 || log \"  WARNING: Failed to stop $service\"\n        \n        # Remove the container\n        log \"  Removing old $service container...\"\n        docker compose -p \"$COMPOSE_PROJECT_NAME\" -f \"$COMPOSE_FILE\" rm -f \"$service\" >> \"$LOG_FILE\" 2>&1 || log \"  WARNING: Failed to remove $service\"\n        \n        # Recreate and start with new image\n        log \"  Starting new $service container...\"\n        if docker compose -p \"$COMPOSE_PROJECT_NAME\" -f \"$COMPOSE_FILE\" up -d --no-deps \"$service\" >> \"$LOG_FILE\" 2>&1; then\n            log \"  ✓ Successfully recreated $service\"\n        else\n            log \"  ERROR: Failed to recreate $service\"\n            write_status \"error\" $current_progress \"Failed to recreate $service - check logs\"\n            return 1\n        fi\n        \n        current_progress=$((current_progress + progress_per_service))\n    done\n    \n    log \"Successfully recreated all containers\"\n    write_status \"complete\" 100 \"System update completed successfully\"\n    log \"System update completed successfully\"\n    \n    return 0\n}\n\ncleanup() {\n    log \"Update sidecar shutting down\"\n    exit 0\n}\n\ntrap cleanup SIGTERM SIGINT\n\n# Main watch loop\nlog \"Update sidecar started - watching for update requests\"\nwrite_status \"idle\" 0 \"Ready for update requests\"\n\nwhile true; do\n    # Check if an update request file exists\n    if [ -f \"$REQUEST_FILE\" ]; then\n        log \"Found update request file\"\n        \n        # Read request details\n        REQUEST_DATA=$(cat \"$REQUEST_FILE\" 2>/dev/null || echo \"{}\")\n        log \"Request data: $REQUEST_DATA\"\n\n        # Extract target tag from request (defaults to \"latest\" if not provided)\n        TARGET_TAG=$(echo \"$REQUEST_DATA\" | jq -r '.target_tag // \"latest\"')\n        log \"Target image tag: ${TARGET_TAG}\"\n\n        # Remove the request file to prevent re-processing\n        rm -f \"$REQUEST_FILE\"\n\n        if perform_update \"$TARGET_TAG\"; then\n            log \"Update completed successfully\"\n        else\n            log \"Update failed - see logs for details\"\n        fi\n        \n        sleep 5\n        write_status \"idle\" 0 \"Ready for update requests\"\n    fi\n    \n    # Sleep before next check (1 second polling)\n    sleep 1\ndone\n"
  },
  {
    "path": "install/start_nomad.sh",
    "content": "#!/bin/bash\n\necho \"Finding Project N.O.M.A.D containers...\"\n\n# -a to include all containers (running and stopped)\ncontainers=$(docker ps -a --filter \"name=^nomad_\" --format \"{{.Names}}\")\n\nif [ -z \"$containers\" ]; then\n    echo \"No containers found for Project N.O.M.A.D. Is it installed?\"\n    exit 0\nfi\n\necho \"Found the following containers:\"\necho \"$containers\"\necho \"\"\n\nfor container in $containers; do\n    echo \"Starting container: $container\"\n    if docker start \"$container\"; then\n        echo \"✓ Successfully started $container\"\n    else\n        echo \"✗ Failed to start $container\"\n    fi\n    echo \"\"\ndone\n\necho \"Finished initiating start of all Project N.O.M.A.D containers.\"\n"
  },
  {
    "path": "install/stop_nomad.sh",
    "content": "#!/bin/bash\n\necho \"Finding running Docker containers for Project N.O.M.A.D...\"\n\ncontainers=$(docker ps --filter \"name=^nomad_\" --format \"{{.Names}}\")\n\nif [ -z \"$containers\" ]; then\n    echo \"No running containers found for Project N.O.M.A.D.\"\n    exit 0\nfi\n\necho \"Found the following running containers:\"\necho \"$containers\"\necho \"\"\n\nfor container in $containers; do\n    echo \"Gracefully stopping container: $container\"\n    if docker stop \"$container\"; then\n        echo \"✓ Successfully stopped $container\"\n    else\n        echo \"✗ Failed to stop $container\"\n    fi\n    echo \"\"\ndone\n\necho \"Finished initiating graceful shutdown of all Project N.O.M.A.D containers.\"\n"
  },
  {
    "path": "install/uninstall_nomad.sh",
    "content": "#!/bin/bash\n\n# Project N.O.M.A.D. Uninstall Script\n\n###################################################################################################################################################################################################\n\n# Script                | Project N.O.M.A.D. Uninstall Script\n# Version               | 1.0.0\n# Author                | Crosstalk Solutions, LLC\n# Website               | https://crosstalksolutions.com\n\n###################################################################################################################################################################################################\n#                                                                                                                                                                                                 #\n#                                                                                  Constants & Variables                                                                                          #\n#                                                                                                                                                                                                 #\n###################################################################################################################################################################################################\n\nNOMAD_DIR=\"/opt/project-nomad\"\nMANAGEMENT_COMPOSE_FILE=\"${NOMAD_DIR}/compose.yml\"\n\n###################################################################################################################################################################################################\n#                                                                                                                                                                                                 #\n#                                                                                     Functions                                                                                                   #\n#                                                                                                                                                                                                 #\n###################################################################################################################################################################################################\n\ncheck_has_sudo() {\n  if sudo -n true 2>/dev/null; then\n    echo -e \"${GREEN}#${RESET} User has sudo permissions.\\\\n\"\n  else\n    echo \"User does not have sudo permissions\"\n    header_red\n    echo -e \"${RED}#${RESET} This script requires sudo permissions to run. Please run the script with sudo.\\\\n\"\n    echo -e \"${RED}#${RESET} For example: sudo bash $(basename \"$0\")\"\n    exit 1\n  fi\n}\n\ncheck_current_directory(){\n  if [ \"$(pwd)\" == \"${NOMAD_DIR}\" ]; then\n    echo \"Please run this script from a directory other than ${NOMAD_DIR}.\"\n    exit 1\n  fi\n}\n\nensure_management_compose_file_exists(){\n  if [ ! -f \"${MANAGEMENT_COMPOSE_FILE}\" ]; then\n    echo \"Unable to find the management Docker Compose file at ${MANAGEMENT_COMPOSE_FILE}. There may be a problem with your Project N.O.M.A.D. installation.\"\n    exit 1\n  fi\n}\n\nget_uninstall_confirmation(){\n  read -p \"This script will remove ALL Project N.O.M.A.D. files and containers. THIS CANNOT BE UNDONE. Are you sure you want to continue? (y/n): \" choice\n  case \"$choice\" in\n    y|Y )\n      echo -e \"User chose to continue with the uninstallation.\"\n      ;;\n    n|N )\n      echo -e \"User chose not to continue with the uninstallation.\"\n      exit 0\n      ;;\n    * )\n      echo \"Invalid Response\"\n      echo \"User chose not to continue with the uninstallation.\"\n      exit 0\n      ;;\n  esac\n}\n\nensure_docker_installed() {\n    if ! command -v docker &> /dev/null; then\n        echo \"Unable to find Docker. There may be a problem with your Docker installation.\"\n        exit 1\n    fi\n}\n\ncheck_docker_compose() {\n  # Check if 'docker compose' (v2 plugin) is available\n  if ! docker compose version &>/dev/null; then\n    echo -e \"${RED}#${RESET} Docker Compose v2 is not installed or not available as a Docker plugin.\"\n    echo -e \"${YELLOW}#${RESET} This script requires 'docker compose' (v2), not 'docker-compose' (v1).\"\n    echo -e \"${YELLOW}#${RESET} Please read the Docker documentation at https://docs.docker.com/compose/install/ for instructions on how to install Docker Compose v2.\"\n    exit 1\n  fi\n}\n\nstorage_cleanup() {\n  read -p \"Do you want to delete the Project N.O.M.A.D. storage directory (${NOMAD_DIR})? This is best if you want to start a completely fresh install. This will PERMANENTLY DELETE all stored Nomad data and can't be undone! (y/N): \" delete_dir_choice\n  case \"$delete_dir_choice\" in\n      y|Y )\n          echo \"Removing Project N.O.M.A.D. files...\"\n          if rm -rf \"${NOMAD_DIR}\"; then\n              echo \"Project N.O.M.A.D. files removed.\"\n          else\n              echo \"Warning: Failed to fully remove ${NOMAD_DIR}. You may need to remove it manually.\"\n          fi\n          ;;\n      * )\n          echo \"Skipping removal of ${NOMAD_DIR}.\"\n          ;;\n  esac\n}\n\nuninstall_nomad() {\n    echo \"Stopping and removing Project N.O.M.A.D. management containers...\"\n    docker compose -p project-nomad -f \"${MANAGEMENT_COMPOSE_FILE}\" down\n    echo \"Allowing some time for management containers to stop...\"\n    sleep 5\n\n\n    # Stop and remove all containers where name starts with \"nomad_\"\n    echo \"Stopping and removing all Project N.O.M.A.D. app containers...\"\n    docker ps -a --filter \"name=^nomad_\" --format \"{{.Names}}\" | xargs -r docker rm -f\n    echo \"Allowing some time for app containers to stop...\"\n    sleep 5\n\n    echo \"Containers should be stopped now.\"\n\n    # Remove the shared Docker network (may still exist if app containers were using it during compose down)\n    echo \"Removing project-nomad_default network if it exists...\"\n    docker network rm project-nomad_default 2>/dev/null && echo \"Network removed.\" || echo \"Network already removed or not found.\"\n\n    # Remove the shared update volume\n    echo \"Removing project-nomad_nomad-update-shared volume if it exists...\"\n    docker volume rm project-nomad_nomad-update-shared 2>/dev/null && echo \"Volume removed.\" || echo \"Volume already removed or not found.\"\n\n    # Prompt user for storage cleanup and handle it if so\n    storage_cleanup\n\n    echo \"Project N.O.M.A.D. has been uninstalled. We hope to see you again soon!\"\n}\n\n###################################################################################################################################################################################################\n#                                                                                                                                                                                                 #\n#                                                                                       Main                                                                                                      #\n#                                                                                                                                                                                                 #\n###################################################################################################################################################################################################\ncheck_has_sudo\ncheck_current_directory\nensure_management_compose_file_exists\nensure_docker_installed\ncheck_docker_compose\nget_uninstall_confirmation\nuninstall_nomad"
  },
  {
    "path": "install/update_nomad.sh",
    "content": "#!/bin/bash\n\n# Project N.O.M.A.D. Update Script\n\n###################################################################################################################################################################################################\n\n# Script                | Project N.O.M.A.D. Update Script\n# Version               | 1.0.1\n# Author                | Crosstalk Solutions, LLC\n# Website               | https://crosstalksolutions.com\n\n###################################################################################################################################################################################################\n#                                                                                                                                                                                                 #\n#                                                                                           Color Codes                                                                                           #\n#                                                                                                                                                                                                 #\n###################################################################################################################################################################################################\n\nRESET='\\033[0m'\nYELLOW='\\033[1;33m'\nWHITE_R='\\033[39m' # Same as GRAY_R for terminals with white background.\nGRAY_R='\\033[39m'\nRED='\\033[1;31m' # Light Red.\nGREEN='\\033[1;32m' # Light Green.\n\n###################################################################################################################################################################################################\n#                                                                                                                                                                                                 #\n#                                                                                           Functions                                                                                             #\n#                                                                                                                                                                                                 #\n###################################################################################################################################################################################################\n\ncheck_has_sudo() {\n  if sudo -n true 2>/dev/null; then\n    echo -e \"${GREEN}#${RESET} User has sudo permissions.\\\\n\"\n  else\n    echo \"User does not have sudo permissions\"\n    header_red\n    echo -e \"${RED}#${RESET} This script requires sudo permissions to run. Please run the script with sudo.\\\\n\"\n    echo -e \"${RED}#${RESET} For example: sudo bash $(basename \"$0\")\"\n    exit 1\n  fi\n}\n\ncheck_is_bash() {\n  if [[ -z \"$BASH_VERSION\" ]]; then\n    header_red\n    echo -e \"${RED}#${RESET} This script requires bash to run. Please run the script using bash.\\\\n\"\n    echo -e \"${RED}#${RESET} For example: bash $(basename \"$0\")\"\n    exit 1\n  fi\n    echo -e \"${GREEN}#${RESET} This script is running in bash.\\\\n\"\n}\n\ncheck_is_debian_based() {\n  if [[ ! -f /etc/debian_version ]]; then\n    header_red\n    echo -e \"${RED}#${RESET} This script is designed to run on Debian-based systems only.\\\\n\"\n    echo -e \"${RED}#${RESET} Please run this script on a Debian-based system and try again.\"\n    exit 1\n  fi\n    echo -e \"${GREEN}#${RESET} This script is running on a Debian-based system.\\\\n\"\n}\n\nget_update_confirmation(){\n  read -p \"This script will update Project N.O.M.A.D. and its dependencies on your machine. No data loss is expected, but you should always back up your data before proceeding. Are you sure you want to continue? (y/n): \" choice\n  case \"$choice\" in\n    y|Y )\n      echo -e \"${GREEN}#${RESET} User chose to continue with the update.\"\n      ;;\n    n|N )\n      echo -e \"${RED}#${RESET} User chose not to continue with the update.\"\n      exit 0\n      ;;\n    * )\n      echo \"Invalid Response\"\n      echo \"User chose not to continue with the update.\"\n      exit 0\n      ;;\n  esac\n}\n\nensure_docker_installed_and_running() {\n  if ! command -v docker &> /dev/null; then\n    echo -e \"${RED}#${RESET} Docker is not installed. This is unexpected, as Project N.O.M.A.D. requires Docker to run. Did you mean to use the install script instead of the update script?\"\n    exit 1\n  fi\n\n  if ! systemctl is-active --quiet docker; then\n    echo -e \"${RED}#${RESET} Docker is not running. Attempting to start Docker...\"\n    sudo systemctl start docker\n    if ! systemctl is-active --quiet docker; then\n      echo -e \"${RED}#${RESET} Failed to start Docker. Please start Docker and try again.\"\n      exit 1\n    fi\n  fi\n}\n\ncheck_docker_compose() {\n  # Check if 'docker compose' (v2 plugin) is available\n  if ! docker compose version &>/dev/null; then\n    echo -e \"${RED}#${RESET} Docker Compose v2 is not installed or not available as a Docker plugin.\"\n    echo -e \"${YELLOW}#${RESET} This script requires 'docker compose' (v2), not 'docker-compose' (v1).\"\n    echo -e \"${YELLOW}#${RESET} Please read the Docker documentation at https://docs.docker.com/compose/install/ for instructions on how to install Docker Compose v2.\"\n    exit 1\n  fi\n}\n\nensure_docker_compose_file_exists() {\n  if [ ! -f \"/opt/project-nomad/compose.yml\" ]; then\n    echo -e \"${RED}#${RESET} compose.yml file not found. Please ensure it exists at /opt/project-nomad/compose.yml.\"\n    exit 1\n  fi\n}\n\nforce_recreate() {\n  echo -e \"${YELLOW}#${RESET} Pulling the latest Docker images...\"\n  if ! docker compose -p project-nomad -f /opt/project-nomad/compose.yml pull; then\n    echo -e \"${RED}#${RESET} Failed to pull the latest Docker images. Please check your network connection and the Docker registry status, then try again.\"\n    exit 1\n  fi\n  \n  echo -e \"${YELLOW}#${RESET} Forcing recreation of containers...\"\n  if ! docker compose -p project-nomad -f /opt/project-nomad/compose.yml up -d --force-recreate; then\n    echo -e \"${RED}#${RESET} Failed to recreate containers. Please check the Docker logs for more details.\"\n    exit 1\n  fi\n}\n\nget_local_ip() {\n  local_ip_address=$(hostname -I | awk '{print $1}')\n  if [[ -z \"$local_ip_address\" ]]; then\n    echo -e \"${RED}#${RESET} Unable to determine local IP address. Please check your network configuration.\"\n    # Don't exit if we can't determine the local IP address, it's not critical for the installation\n  fi\n}\n\nsuccess_message() {\n  echo -e \"${GREEN}#${RESET} Project N.O.M.A.D installation completed successfully!\\\\n\"\n  echo -e \"${GREEN}#${RESET} Installation files are located at /opt/project-nomad\\\\n\\n\"\n  echo -e \"${GREEN}#${RESET} Project N.O.M.A.D's Command Center should automatically start whenever your device reboots. However, if you need to start it manually, you can always do so by running: ${WHITE_R}${nomad_dir}/start_nomad.sh${RESET}\\\\n\"\n  echo -e \"${GREEN}#${RESET} You can now access the management interface at http://localhost:8080 or http://${local_ip_address}:8080\\\\n\"\n  echo -e \"${GREEN}#${RESET} Thank you for supporting Project N.O.M.A.D!\\\\n\"\n}\n\n###################################################################################################################################################################################################\n#                                                                                                                                                                                                 #\n#                                                                                           Main Script                                                                                           #\n#                                                                                                                                                                                                 #\n###################################################################################################################################################################################################\n\n# Pre-flight checks\ncheck_is_debian_based\ncheck_is_bash\ncheck_has_sudo\n\n# Main update\nget_update_confirmation\nensure_docker_installed_and_running\ncheck_docker_compose\nensure_docker_compose_file_exists\nforce_recreate\nget_local_ip\nsuccess_message\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"project-nomad\",\n  \"version\": \"1.30.2\",\n  \"description\": \"\\\"\",\n  \"main\": \"index.js\",\n  \"scripts\": {\n    \"test\": \"echo \\\"Error: no test specified\\\" && exit 1\"\n  },\n  \"author\": \"Crosstalk Solutions, LLC\",\n  \"license\": \"ISC\"\n}\n"
  }
]